pkg-track 1.0.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.
package/src/db.js ADDED
@@ -0,0 +1,189 @@
1
+ const Database = require('better-sqlite3');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const fs = require('fs');
5
+
6
+ const DB_DIR = path.join(os.homedir(), '.pkg-tracker');
7
+ const DB_PATH = path.join(DB_DIR, 'packages.db');
8
+
9
+ // Ensure directory exists with secure permissions (user only)
10
+ if (!fs.existsSync(DB_DIR)) {
11
+ fs.mkdirSync(DB_DIR, { recursive: true, mode: 0o700 });
12
+ }
13
+
14
+ const db = new Database(DB_PATH);
15
+
16
+ // Initialize schema
17
+ db.exec(`
18
+ CREATE TABLE IF NOT EXISTS packages (
19
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
20
+ tracking_number TEXT UNIQUE NOT NULL,
21
+ carrier TEXT,
22
+ retailer TEXT,
23
+ description TEXT,
24
+ status TEXT DEFAULT 'pending',
25
+ expected_delivery TEXT,
26
+ shipped_date INTEGER,
27
+ last_email_date INTEGER,
28
+ last_updated INTEGER DEFAULT (strftime('%s', 'now')),
29
+ created_at INTEGER DEFAULT (strftime('%s', 'now')),
30
+ delivered_at INTEGER,
31
+ email_id TEXT
32
+ );
33
+
34
+ CREATE INDEX IF NOT EXISTS idx_tracking ON packages(tracking_number);
35
+ CREATE INDEX IF NOT EXISTS idx_status ON packages(status);
36
+ CREATE INDEX IF NOT EXISTS idx_delivered ON packages(delivered_at);
37
+ `);
38
+
39
+ // Migration: Add new columns if they don't exist
40
+ function migrateSchema() {
41
+ const columns = db.prepare("PRAGMA table_info(packages)").all();
42
+ const columnNames = columns.map(c => c.name);
43
+
44
+ if (!columnNames.includes('shipped_date')) {
45
+ db.exec('ALTER TABLE packages ADD COLUMN shipped_date INTEGER');
46
+ console.log('Migration: Added shipped_date column');
47
+ }
48
+
49
+ if (!columnNames.includes('last_email_date')) {
50
+ db.exec('ALTER TABLE packages ADD COLUMN last_email_date INTEGER');
51
+ console.log('Migration: Added last_email_date column');
52
+ }
53
+ }
54
+
55
+ migrateSchema();
56
+
57
+ /**
58
+ * Insert or update a package
59
+ */
60
+ function upsertPackage(data) {
61
+ // Auto-infer status based on shipped date if provided
62
+ if (data.shippedDate && !data.status) {
63
+ data.status = inferStatus(data.shippedDate);
64
+ }
65
+
66
+ const stmt = db.prepare(`
67
+ INSERT INTO packages (tracking_number, carrier, retailer, description, email_id, shipped_date, last_email_date, status)
68
+ VALUES (@trackingNumber, @carrier, @retailer, @description, @emailId, @shippedDate, @lastEmailDate, @status)
69
+ ON CONFLICT(tracking_number) DO UPDATE SET
70
+ carrier = COALESCE(@carrier, carrier),
71
+ retailer = COALESCE(@retailer, retailer),
72
+ description = COALESCE(@description, description),
73
+ shipped_date = COALESCE(@shippedDate, shipped_date),
74
+ last_email_date = COALESCE(@lastEmailDate, last_email_date),
75
+ status = COALESCE(@status, status),
76
+ email_id = @emailId,
77
+ last_updated = strftime('%s', 'now')
78
+ RETURNING *
79
+ `);
80
+
81
+ return stmt.get(data);
82
+ }
83
+
84
+ /**
85
+ * Infer package status based on shipped date
86
+ */
87
+ function inferStatus(shippedDateTimestamp) {
88
+ if (!shippedDateTimestamp) return 'pending';
89
+
90
+ const now = Math.floor(Date.now() / 1000);
91
+ const daysSinceShipped = (now - shippedDateTimestamp) / (24 * 60 * 60);
92
+
93
+ if (daysSinceShipped < 1) {
94
+ return 'pending';
95
+ } else if (daysSinceShipped < 7) {
96
+ return 'in_transit';
97
+ } else if (daysSinceShipped < 14) {
98
+ return 'delayed';
99
+ } else {
100
+ return 'delivered'; // Assume delivered after 2 weeks
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Get all packages
106
+ */
107
+ function getAllPackages(includeDelivered = false) {
108
+ let query = 'SELECT * FROM packages';
109
+
110
+ if (!includeDelivered) {
111
+ query += ' WHERE delivered_at IS NULL';
112
+ }
113
+
114
+ query += ' ORDER BY created_at DESC';
115
+
116
+ return db.prepare(query).all();
117
+ }
118
+
119
+ /**
120
+ * Get package by tracking number
121
+ */
122
+ function getPackage(trackingNumber) {
123
+ return db.prepare('SELECT * FROM packages WHERE tracking_number = ?').get(trackingNumber);
124
+ }
125
+
126
+ /**
127
+ * Update package status
128
+ */
129
+ function updatePackageStatus(trackingNumber, status, expectedDelivery = null) {
130
+ const updates = { status };
131
+
132
+ if (status === 'delivered') {
133
+ updates.delivered_at = Math.floor(Date.now() / 1000);
134
+ }
135
+
136
+ if (expectedDelivery) {
137
+ updates.expected_delivery = expectedDelivery;
138
+ }
139
+
140
+ const fields = Object.keys(updates).map(k => `${k} = @${k}`).join(', ');
141
+ const stmt = db.prepare(`
142
+ UPDATE packages
143
+ SET ${fields}, last_updated = strftime('%s', 'now')
144
+ WHERE tracking_number = @trackingNumber
145
+ `);
146
+
147
+ return stmt.run({ ...updates, trackingNumber });
148
+ }
149
+
150
+ /**
151
+ * Delete a package
152
+ */
153
+ function deletePackage(trackingNumber) {
154
+ return db.prepare('DELETE FROM packages WHERE tracking_number = ?').run(trackingNumber);
155
+ }
156
+
157
+ /**
158
+ * Delete packages by carrier
159
+ */
160
+ function deletePackagesByCarrier(carriers) {
161
+ const placeholders = carriers.map(() => '?').join(',');
162
+ const stmt = db.prepare(`DELETE FROM packages WHERE carrier IN (${placeholders})`);
163
+ return stmt.run(...carriers);
164
+ }
165
+
166
+ /**
167
+ * Get package stats (only for supported carriers)
168
+ */
169
+ function getStats() {
170
+ const SUPPORTED = ['UPS', 'FedEx', 'USPS', 'Amazon Logistics'];
171
+ const placeholders = SUPPORTED.map(() => '?').join(',');
172
+
173
+ const total = db.prepare(`SELECT COUNT(*) as count FROM packages WHERE carrier IN (${placeholders})`).get(...SUPPORTED).count;
174
+ const delivered = db.prepare(`SELECT COUNT(*) as count FROM packages WHERE carrier IN (${placeholders}) AND delivered_at IS NOT NULL`).get(...SUPPORTED).count;
175
+ const inTransit = db.prepare(`SELECT COUNT(*) as count FROM packages WHERE carrier IN (${placeholders}) AND status = ? AND delivered_at IS NULL`).get(...SUPPORTED, 'in_transit').count;
176
+
177
+ return { total, delivered, inTransit, pending: total - delivered - inTransit };
178
+ }
179
+
180
+ module.exports = {
181
+ upsertPackage,
182
+ getAllPackages,
183
+ getPackage,
184
+ updatePackageStatus,
185
+ deletePackage,
186
+ deletePackagesByCarrier,
187
+ getStats,
188
+ db
189
+ };
package/src/gmail.js ADDED
@@ -0,0 +1,371 @@
1
+ const { google } = require('googleapis');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const open = require('open');
6
+ const chalk = require('chalk');
7
+ const ora = require('ora');
8
+ const http = require('http');
9
+ const url = require('url');
10
+ const { extractTrackingNumbers, detectRetailer, extractShippedDate, extractProductDetails } = require('./parser');
11
+ const { upsertPackage } = require('./db');
12
+
13
+ const CONFIG_DIR = path.join(os.homedir(), '.pkg-tracker');
14
+ const TOKEN_PATH = path.join(CONFIG_DIR, 'gmail-token.json');
15
+ const CREDENTIALS_PATH = path.join(CONFIG_DIR, 'credentials.json');
16
+
17
+ const SCOPES = ['https://www.googleapis.com/auth/gmail.readonly'];
18
+
19
+ /**
20
+ * Initialize Gmail OAuth
21
+ */
22
+ async function initGmail() {
23
+ console.log(chalk.blue('\n📧 Gmail OAuth Setup\n'));
24
+ console.log(chalk.gray('Secure, read-only access to scan your email for shipping confirmations'));
25
+ console.log(chalk.gray('Your credentials are stored locally - no data leaves your computer\n'));
26
+
27
+ // Check if credentials exist
28
+ if (!fs.existsSync(CREDENTIALS_PATH)) {
29
+ console.log(chalk.yellow('⚠️ Gmail OAuth credentials not found.'));
30
+ console.log('\nTo set up Gmail access:');
31
+ console.log('1. Go to https://console.cloud.google.com/apis/credentials');
32
+ console.log('2. Create OAuth 2.0 Client ID (Desktop app)');
33
+ console.log('3. Download the JSON file');
34
+ console.log(`4. Save it as: ${CREDENTIALS_PATH}\n`);
35
+
36
+ // Ensure directory exists with secure permissions
37
+ if (!fs.existsSync(CONFIG_DIR)) {
38
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
39
+ }
40
+
41
+ throw new Error('Please set up Gmail credentials first');
42
+ }
43
+
44
+ const credentials = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf8'));
45
+ const { client_secret, client_id, redirect_uris } = credentials.installed || credentials.web;
46
+
47
+ // Parse the redirect URI to get the port
48
+ const redirectUri = redirect_uris[0];
49
+ const redirectUrl = new URL(redirectUri);
50
+ const port = parseInt(redirectUrl.port) || 3000;
51
+
52
+ const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirectUri);
53
+
54
+ // Generate auth URL
55
+ const authUrl = oAuth2Client.generateAuthUrl({
56
+ access_type: 'offline',
57
+ scope: SCOPES,
58
+ });
59
+
60
+ console.log(chalk.blue('Opening browser for Gmail authorization...'));
61
+ console.log(chalk.gray(`Starting local server on port ${port}...\n`));
62
+
63
+ // Create a promise that resolves when we get the code
64
+ const code = await new Promise((resolve, reject) => {
65
+ const server = http.createServer(async (req, res) => {
66
+ try {
67
+ console.log(chalk.gray(`Received request: ${req.url}`));
68
+ const queryParams = url.parse(req.url, true).query;
69
+
70
+ if (queryParams.code) {
71
+ // Send success page
72
+ res.writeHead(200, { 'Content-Type': 'text/html' });
73
+ res.end(`
74
+ <!DOCTYPE html>
75
+ <html>
76
+ <head>
77
+ <meta charset="UTF-8">
78
+ <title>Authorization Successful</title>
79
+ <style>
80
+ body {
81
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
82
+ display: flex;
83
+ align-items: center;
84
+ justify-content: center;
85
+ height: 100vh;
86
+ margin: 0;
87
+ background: #f5f5f5;
88
+ }
89
+ .container {
90
+ text-align: center;
91
+ background: white;
92
+ padding: 3rem;
93
+ border-radius: 12px;
94
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
95
+ }
96
+ h1 { color: #22c55e; margin: 0 0 1rem; }
97
+ p { color: #666; margin: 0; }
98
+ </style>
99
+ </head>
100
+ <body>
101
+ <div class="container">
102
+ <h1>✓ Authorization Successful!</h1>
103
+ <p>You can close this window and return to the terminal.</p>
104
+ </div>
105
+ </body>
106
+ </html>
107
+ `);
108
+
109
+ console.log(chalk.green('\n✓ Received authorization code'));
110
+
111
+ // Give time for response to be sent
112
+ setTimeout(() => {
113
+ server.close();
114
+ resolve(queryParams.code);
115
+ }, 100);
116
+ } else if (queryParams.error) {
117
+ res.writeHead(400, { 'Content-Type': 'text/html' });
118
+ res.end(`
119
+ <!DOCTYPE html>
120
+ <html>
121
+ <head>
122
+ <meta charset="UTF-8">
123
+ <title>Authorization Failed</title>
124
+ <style>
125
+ body {
126
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
127
+ display: flex;
128
+ align-items: center;
129
+ justify-content: center;
130
+ height: 100vh;
131
+ margin: 0;
132
+ background: #f5f5f5;
133
+ }
134
+ .container {
135
+ text-align: center;
136
+ background: white;
137
+ padding: 3rem;
138
+ border-radius: 12px;
139
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
140
+ }
141
+ h1 { color: #ef4444; margin: 0 0 1rem; }
142
+ p { color: #666; margin: 0; }
143
+ </style>
144
+ </head>
145
+ <body>
146
+ <div class="container">
147
+ <h1>✗ Authorization Failed</h1>
148
+ <p>Error: ${queryParams.error}</p>
149
+ </div>
150
+ </body>
151
+ </html>
152
+ `);
153
+
154
+ setTimeout(() => {
155
+ server.close();
156
+ reject(new Error(`OAuth error: ${queryParams.error}`));
157
+ }, 100);
158
+ }
159
+ } catch (err) {
160
+ server.close();
161
+ reject(err);
162
+ }
163
+ });
164
+
165
+ // Handle server errors
166
+ server.on('error', (err) => {
167
+ if (err.code === 'EADDRINUSE') {
168
+ console.log(chalk.red(`\n✗ Port ${port} is already in use`));
169
+ console.log(chalk.yellow('Please close any other applications using this port and try again.\n'));
170
+ }
171
+ reject(err);
172
+ });
173
+
174
+ server.listen(port, '127.0.0.1', () => {
175
+ console.log(chalk.green(`✓ Server listening on http://127.0.0.1:${port}`));
176
+ console.log(chalk.gray('Opening browser...\n'));
177
+
178
+ // Wait a bit for server to be fully ready, then open browser
179
+ setTimeout(() => {
180
+ open(authUrl);
181
+ }, 500);
182
+ });
183
+
184
+ // Timeout after 5 minutes
185
+ setTimeout(() => {
186
+ server.close();
187
+ reject(new Error('Authorization timeout - no response received after 5 minutes'));
188
+ }, 5 * 60 * 1000);
189
+ });
190
+
191
+ const { tokens } = await oAuth2Client.getToken(code);
192
+ oAuth2Client.setCredentials(tokens);
193
+
194
+ // Save token with secure permissions (user read/write only)
195
+ fs.writeFileSync(TOKEN_PATH, JSON.stringify(tokens), { mode: 0o600 });
196
+ console.log(chalk.green('\n✓ Gmail access granted and saved!'));
197
+ }
198
+
199
+ /**
200
+ * Get authorized Gmail client
201
+ */
202
+ function getGmailClient() {
203
+ if (!fs.existsSync(CREDENTIALS_PATH)) {
204
+ throw new Error('Gmail credentials not found. Run `pkg init` first.');
205
+ }
206
+
207
+ if (!fs.existsSync(TOKEN_PATH)) {
208
+ throw new Error('Gmail not authorized. Run `pkg init` first.');
209
+ }
210
+
211
+ const credentials = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf8'));
212
+ const tokens = JSON.parse(fs.readFileSync(TOKEN_PATH, 'utf8'));
213
+
214
+ const { client_secret, client_id, redirect_uris } = credentials.installed || credentials.web;
215
+ const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
216
+
217
+ oAuth2Client.setCredentials(tokens);
218
+
219
+ return google.gmail({ version: 'v1', auth: oAuth2Client });
220
+ }
221
+
222
+ /**
223
+ * Sync packages from Gmail
224
+ */
225
+ async function syncPackages(days = 30) {
226
+ console.log(chalk.blue('\n📧 Gmail Package Scanner\n'));
227
+ console.log(chalk.gray('Securely scanning your Gmail for shipping confirmations...'));
228
+ console.log(chalk.gray(`Searching the last ${days} days for packages from UPS, FedEx, USPS, and Amazon`));
229
+ console.log(chalk.gray('💡 Tip: Use --days <number> to customize the date range (max 30 days)\n'));
230
+
231
+ const spinner = ora('Scanning Gmail for shipping confirmations...').start();
232
+
233
+ try {
234
+ const gmail = getGmailClient();
235
+
236
+ // Expanded search for shipping-related emails
237
+ // Search both subject and body for common shipping terms
238
+ const query = `(
239
+ shipped OR
240
+ "has shipped" OR
241
+ tracking OR
242
+ delivered OR
243
+ "out for delivery" OR
244
+ "order confirmation" OR
245
+ "shipment confirmation" OR
246
+ "your order" OR
247
+ "on its way" OR
248
+ "package update" OR
249
+ "delivery update" OR
250
+ "1Z" OR
251
+ "TBA" OR
252
+ "fedex" OR
253
+ "usps" OR
254
+ "ups" OR
255
+ "amazon"
256
+ ) newer_than:${days}d`.replace(/\s+/g, ' ');
257
+
258
+ console.log(chalk.gray(`\nSearch query: ${query.substring(0, 100)}...`));
259
+
260
+ const response = await gmail.users.messages.list({
261
+ userId: 'me',
262
+ q: query,
263
+ maxResults: 200, // Increased from 100
264
+ });
265
+
266
+ const messages = response.data.messages || [];
267
+
268
+ if (messages.length === 0) {
269
+ spinner.succeed(chalk.yellow('No shipping emails found'));
270
+ return;
271
+ }
272
+
273
+ spinner.text = `Found ${messages.length} emails, extracting tracking numbers...`;
274
+ console.log(chalk.gray(`Processing ${messages.length} emails (last ${days} days)...\n`));
275
+
276
+ let foundCount = 0;
277
+ let emailsWithTracking = 0;
278
+ const skippedEmails = [];
279
+
280
+ for (const message of messages) {
281
+ const msg = await gmail.users.messages.get({
282
+ userId: 'me',
283
+ id: message.id,
284
+ format: 'full',
285
+ });
286
+
287
+ // Extract headers
288
+ const headers = msg.data.payload.headers;
289
+ const subject = headers.find(h => h.name === 'Subject')?.value || '';
290
+ const from = headers.find(h => h.name === 'From')?.value || '';
291
+ const date = headers.find(h => h.name === 'Date')?.value || '';
292
+
293
+ // Get email body
294
+ let body = '';
295
+ if (msg.data.payload.body.data) {
296
+ body = Buffer.from(msg.data.payload.body.data, 'base64').toString();
297
+ } else if (msg.data.payload.parts) {
298
+ const textPart = msg.data.payload.parts.find(p => p.mimeType === 'text/plain');
299
+ if (textPart && textPart.body.data) {
300
+ body = Buffer.from(textPart.body.data, 'base64').toString();
301
+ }
302
+ }
303
+
304
+ // Extract tracking numbers
305
+ const trackingNumbers = extractTrackingNumbers(subject + ' ' + body);
306
+
307
+ if (trackingNumbers.length > 0) {
308
+ emailsWithTracking++;
309
+ const emailData = { from, subject, body, date };
310
+ const retailer = detectRetailer(emailData);
311
+ const shippedDate = extractShippedDate(emailData);
312
+ const productDetails = extractProductDetails(emailData);
313
+
314
+ // Parse email date to timestamp
315
+ let emailTimestamp = null;
316
+ try {
317
+ if (date) {
318
+ emailTimestamp = Math.floor(new Date(date).getTime() / 1000);
319
+ }
320
+ } catch (e) {
321
+ // Use current time if parsing fails
322
+ emailTimestamp = Math.floor(Date.now() / 1000);
323
+ }
324
+
325
+ for (const { trackingNumber, carrier } of trackingNumbers) {
326
+ upsertPackage({
327
+ trackingNumber,
328
+ carrier,
329
+ retailer,
330
+ description: productDetails,
331
+ emailId: message.id,
332
+ shippedDate,
333
+ lastEmailDate: emailTimestamp,
334
+ });
335
+ foundCount++;
336
+ }
337
+ } else {
338
+ // Track emails without tracking numbers for debugging
339
+ skippedEmails.push({ subject, from });
340
+ }
341
+ }
342
+
343
+ spinner.succeed(chalk.green(`✓ Found ${foundCount} tracking numbers from ${emailsWithTracking}/${messages.length} emails`));
344
+
345
+ if (foundCount > 0) {
346
+ console.log(chalk.gray(`\n💡 Run ${chalk.cyan('pkg open')} to track your packages\n`));
347
+ }
348
+
349
+ // Show sample of skipped emails if verbose
350
+ if (skippedEmails.length > 0 && skippedEmails.length < 10) {
351
+ console.log(chalk.yellow(`⚠️ ${skippedEmails.length} emails had no tracking numbers:`));
352
+ skippedEmails.forEach(({ subject, from }) => {
353
+ console.log(chalk.gray(` - ${subject.substring(0, 60)}...`));
354
+ console.log(chalk.gray(` from: ${from.substring(0, 40)}`));
355
+ });
356
+ console.log();
357
+ } else if (skippedEmails.length >= 10) {
358
+ console.log(chalk.gray(`${skippedEmails.length} emails had no tracking numbers (likely order confirmations)\n`));
359
+ }
360
+
361
+ } catch (error) {
362
+ spinner.fail(chalk.red('Failed to sync'));
363
+ throw error;
364
+ }
365
+ }
366
+
367
+ module.exports = {
368
+ initGmail,
369
+ syncPackages,
370
+ getGmailClient,
371
+ };