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/CHANGELOG.md +53 -0
- package/CODE_OF_CONDUCT.md +42 -0
- package/CONTRIBUTING.md +157 -0
- package/LICENSE +21 -0
- package/PUBLISH.md +188 -0
- package/QUICKSTART.md +136 -0
- package/README.md +393 -0
- package/bin/cli.js +174 -0
- package/package.json +53 -0
- package/pkg-tracker-1.0.0.tgz +0 -0
- package/src/banner.js +34 -0
- package/src/db.js +189 -0
- package/src/gmail.js +371 -0
- package/src/open.js +209 -0
- package/src/parser.js +223 -0
- package/src/tracker.js +103 -0
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
|
+
};
|