@iovdin/maik 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.
@@ -0,0 +1,27 @@
1
+ name: Publish Package
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ permissions:
9
+ id-token: write # Required for OIDC
10
+ contents: read
11
+
12
+ jobs:
13
+ publish:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - uses: actions/setup-node@v4
19
+ with:
20
+ node-version: '20'
21
+ registry-url: 'https://registry.npmjs.org'
22
+
23
+ # Ensure npm 11.5.1 or later is installed
24
+ - name: Update npm
25
+ run: npm install -g npm@latest
26
+ - run: npm i
27
+ - run: npm publish
package/README.md ADDED
@@ -0,0 +1,256 @@
1
+ # Maik - CLI Email Client
2
+
3
+ A command-line tool to download and index emails from an IMAP server.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install -g maik
9
+ ```
10
+
11
+ ## Configuration
12
+
13
+ Create a configuration file at `~/.config/maik/config.txt`:
14
+
15
+ > **Gmail users:** To get an app password, visit [https://myaccount.google.com/apppasswords](https://myaccount.google.com/apppasswords). You need to have 2-Step Verification enabled. Use the generated app password instead of your regular Gmail password.
16
+
17
+ ```
18
+ # IMAP server settings
19
+ host = imap.gmail.com
20
+ user = your-email@example.com
21
+ password = your-password
22
+
23
+ # Output directory for downloaded emails
24
+ output = ~/mail
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ### Download emails
30
+
31
+ ```bash
32
+ # it might take a long time and a lot of space locally
33
+ maik fetch
34
+
35
+ ```
36
+
37
+ ### Index emails
38
+
39
+ After downloading emails, create a SQLite index for fast searching:
40
+
41
+ ```bash
42
+ maik index
43
+ ```
44
+
45
+ The index is stored in `~/<output>/index.sqlite` with the following schema:
46
+
47
+ ```sql
48
+ CREATE TABLE emails (
49
+ uid INTEGER PRIMARY KEY,
50
+ date INTEGER, -- Unix timestamp
51
+ sender TEXT, -- Sender email address
52
+ sender_name TEXT, -- Sender display name
53
+ "to" TEXT, -- Recipients
54
+ subject TEXT,
55
+ size INTEGER,
56
+ message_id TEXT,
57
+ in_reply_to TEXT,
58
+ refs TEXT, -- References header
59
+ cc TEXT,
60
+ bcc TEXT,
61
+ reply_to TEXT,
62
+ delivered_to TEXT,
63
+ attachments TEXT, -- Attachments (filename:mimeType)
64
+ received_spf TEXT
65
+ );
66
+
67
+ ```
68
+
69
+ To query dates in human-readable format, use SQLite's datetime function:
70
+ ```sql
71
+ SELECT uid, datetime(date, 'unixepoch') as date, sender, subject FROM emails;
72
+ ```
73
+
74
+ ## Email Storage
75
+
76
+ Emails are stored in the format:
77
+ ```
78
+ output_dir/
79
+ YYYY-MM/ # Year-Month folder
80
+ <uid>/ # Per-email folder
81
+ source.eml # Raw email file
82
+ ```
83
+
84
+
85
+ ## crontab
86
+
87
+ To download and index emails automatically, add the following to your crontab (`crontab -e`).
88
+ Replace `your_username` with your actual macOS username and adjust the Node.js version path to match your environment (`node --version` to check):
89
+
90
+ ```crontab
91
+ HOME=/Users/your_username
92
+ PATH=/Users/your_username/.nvm/versions/node/v22.20.0/bin:/usr/local/bin:/usr/bin:/bin
93
+
94
+ # Fetch new emails and rebuild the index every 5 minutes, logging output to ~/crontab.log
95
+ */5 * * * * maik fetch && maik index >> $HOME/crontab.log 2>&1
96
+ ```
97
+
98
+ ## Tune
99
+
100
+ Add `maik` as a middleware in your [Tune](https://github.com/iovdin/tune) setup.
101
+
102
+ Edit `~/.tune/default.ctx.js` and add `require("maik/middleware")` to your middleware array:
103
+
104
+ ```js
105
+ const maik = require("maik/middleware")
106
+
107
+ module.exports = [
108
+ // ... your other middleware ...
109
+ maik,
110
+ // ...
111
+ ]
112
+ ```
113
+
114
+ The middleware allows Tune to read email bodies and attachments as files:
115
+
116
+ ```
117
+ mail/YYYY-MM/<uid>/body.txt
118
+ mail/YYYY-MM/<uid>/body.html
119
+ mail/YYYY-MM/<uid>/<attachmentname.ext>
120
+ ```
121
+
122
+
123
+ ### Usage
124
+
125
+ Use `mail.prompt` as a system prompt in your Tune chat. It automatically:
126
+ - Loads the `rf` (read file) and `mistral_ocr` tools
127
+ - Connects a `mail_query` tool wired to your `~/mail/index.sqlite` database
128
+ - Injects the live email table schema
129
+ - Reminds the model to always use `datetime(date, 'unixepoch')` for dates and to `LIMIT` queries
130
+
131
+ ```
132
+ @rf
133
+ @mistral_ocr
134
+
135
+ @{ sqlite | curry filename=~/mail/index.sqlite $name=mail_query }
136
+ You are mail assistant.
137
+
138
+ <emails-table-schema>
139
+ @{| proc sqlite filename=~/mail/index.sqlite text="PRAGMA table_info(emails);" }
140
+ </emails-table-schema>
141
+
142
+ You can read text of email from file
143
+ mail/YYYY-MM/<uid>/body.txt
144
+ or
145
+ mail/YYYY-MM/<uid>/body.html
146
+
147
+ attachment are accessable by path
148
+ mail/YYYY-MM/<uid>/<attachmentname.ext>
149
+
150
+
151
+ Always transform date to datetime(date, 'unixepoch')
152
+ ...
153
+ ```
154
+
155
+ here is a chat example
156
+
157
+ ```chat
158
+ system: @@mail.prompt
159
+ user:
160
+ find emails from HomeNest (company I rent apartment from)
161
+
162
+ assistant:
163
+
164
+ tool_call: mail_query {"format":"table"}
165
+ SELECT uid, datetime(date, 'unixepoch') as dt, sender, sender_name, subject
166
+ FROM emails
167
+ WHERE sender LIKE '%homenest%'
168
+ OR sender_name LIKE '%homenest%'
169
+ OR subject LIKE '%homenest%'
170
+ ORDER BY date DESC LIMIT 5;
171
+
172
+ tool_result:
173
+ +--------+---------------------+---------------------------+-------------+--------------------------------------------+
174
+ | uid | dt | sender | sender_name | subject |
175
+ +--------+---------------------+---------------------------+-------------+--------------------------------------------+
176
+ | 285411 | 2026-05-18 14:22:10 | billing@homenestliving.com| | Invoice period 1-6-2026 to 30-6-2026 |
177
+ | 284992 | 2026-04-19 09:15:44 | billing@homenestliving.com| | Invoice period 1-5-2026 to 31-5-2026 |
178
+ | 284501 | 2026-04-05 11:02:18 | support@homenestliving.com| | Maintenance notice |
179
+ | 283876 | 2026-03-17 15:44:02 | billing@homenestliving.com| | Invoice period 1-4-2026 to 30-4-2026 |
180
+ | 282940 | 2026-02-12 10:28:33 | billing@homenestliving.com| | Invoice period 1-3-2026 to 31-3-2026 |
181
+ +--------+---------------------+---------------------------+-------------+--------------------------------------------+
182
+
183
+ assistant:
184
+ Here are the latest emails from **HomeNest Living (billing@homenestliving.com)**:
185
+
186
+ 1. 2026-05-18 — Invoice period 1-6-2026 to 30-6-2026 (uid 285411)
187
+ 2. 2026-04-19 — Invoice period 1-5-2026 to 31-5-2026 (uid 284992)
188
+ 3. 2026-04-05 — Maintenance notice (uid 284501)
189
+ 4. 2026-03-17 — Invoice period 1-4-2026 to 30-4-2026 (uid 283876)
190
+ 5. 2026-02-12 — Invoice period 1-3-2026 to 31-3-2026 (uid 282940)
191
+
192
+ Would you like me to:
193
+ - Open one of these?
194
+ - Download the latest invoice attachment?
195
+ - Show all emails from HomeNest?
196
+ - Summarize billing history?
197
+
198
+ user:
199
+ can you read the latest one and parse the invoice
200
+
201
+ assistant:
202
+
203
+ tool_call: mail_query {"format":"table"}
204
+ SELECT uid, datetime(date, 'unixepoch') as dt, subject, attachments
205
+ FROM emails
206
+ WHERE uid = 285411 LIMIT 1;
207
+
208
+ tool_result:
209
+ +--------+---------------------+------------------------------------------+-------------------------------------------------------------------+
210
+ | uid | dt | subject | attachments |
211
+ +--------+---------------------+------------------------------------------+-------------------------------------------------------------------+
212
+ | 285411 | 2026-05-18 14:22:10 | Invoice period 1-6-2026 to 30-6-2026 | INV-2026-06-4587.pdf:application/pdf, INV-2026-06-4587.xml:application/xml |
213
+ +--------+---------------------+------------------------------------------+-------------------------------------------------------------------+
214
+
215
+ assistant:
216
+
217
+ tool_call: mistral_ocr {"filename":"mail/2026-05/285411/INV-2026-06-4587.pdf"}
218
+
219
+ tool_result:
220
+
221
+ HomeNest Living
222
+ <cut>
223
+ <cut>
224
+ Email: billing@homenestliving.com
225
+ Website: www.homenestliving.com
226
+
227
+ John Carter
228
+ <cut>
229
+ <cut>
230
+ United Kingdom
231
+
232
+ INVOICE
233
+
234
+ Property: Riverside Apartments, London
235
+ Invoice date: 18-05-2026
236
+ Subject: Invoice period 1-6-2026 to 30-6-2026
237
+ Due date: 05-06-2026
238
+ Invoice number: INV-2026-06-4587
239
+ Customer number: CUST-90421
240
+
241
+ The following amount is due:
242
+
243
+ | Description | Net (£) | VAT (%) | VAT (£) | Total (£) |
244
+ |------------------|---------|---------|---------|-----------|
245
+ | Rent | 1,250.00| 0% | 0.00 | 1,250.00 |
246
+ | Service charges | 150.00 | 0% | 0.00 | 150.00 |
247
+ | TOTAL | 1,400.00| | 0.00 | 1,400.00 |
248
+
249
+ Please transfer the amount before the due date, referencing the invoice and customer number.
250
+
251
+ IBAN: GB29NWBK60161331926819
252
+ Account name: Urban Living Group Ltd
253
+ BIC: NWBKGB2L
254
+ VAT number: GB123456789
255
+ Company number: 09876543
256
+ ```
package/cli.js ADDED
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { Command } = require('commander');
4
+ const {
5
+ loadConfig,
6
+ mergeConfig,
7
+ connectToImap,
8
+ ensureDirectory,
9
+ formatDate,
10
+ getLatestUid,
11
+ downloadEmails,
12
+ parseEmails,
13
+ indexEmails
14
+ } = require('./index.js');
15
+
16
+ const program = new Command();
17
+
18
+ program
19
+ .name('maik')
20
+ .description('CLI tool to download and index emails from IMAP server')
21
+ .version('1.0.0');
22
+
23
+ // Fetch command
24
+ program
25
+ .command('fetch')
26
+ .description('Download emails from IMAP server to local folder')
27
+ .option('-h, --host <host>', 'IMAP server hostname')
28
+ .option('-u, --user <user>', 'Email username')
29
+ .option('-p, --password <password>', 'Email password')
30
+ .option('-o, --output <directory>', 'Output directory for emails')
31
+ .option('-v, --verbose', 'Show detailed progress', false)
32
+ .option('--port <port>', 'IMAP server port', '993')
33
+ .option('--tls', 'Use TLS', true)
34
+ .option('--box <mailbox>', 'Mailbox to download', 'INBOX')
35
+ .option('--limit <number>', 'Limit number of emails to download', '0')
36
+ .action(async (options) => {
37
+ const config = await loadConfig();
38
+ const mergedOptions = mergeConfig(options, config);
39
+ await downloadEmails(mergedOptions);
40
+ });
41
+
42
+ // Parse command
43
+ program
44
+ .command('parse')
45
+ .description('Parse source.eml files and extract body.txt, body.html, and attachments')
46
+ .option('-o, --output <directory>', 'Mail folder directory')
47
+ .option('-v, --verbose', 'Show detailed progress', false)
48
+ .action(async (options) => {
49
+ const config = await loadConfig();
50
+ const mergedOptions = mergeConfig(options, config);
51
+ await parseEmails(mergedOptions);
52
+ });
53
+
54
+ // Index command
55
+ program
56
+ .command('index')
57
+ .description('Index .eml files into SQLite database')
58
+ .option('-o, --output <directory>', 'Mail folder directory')
59
+ .option('-v, --verbose', 'Show detailed progress', false)
60
+ .option('--clean', 'Remove existing index database and re-index everything', false)
61
+ .action(async (options) => {
62
+ const config = await loadConfig();
63
+ const mergedOptions = mergeConfig(options, config);
64
+ await indexEmails(mergedOptions);
65
+ });
66
+
67
+ // Parse and run
68
+ program.parse();
@@ -0,0 +1,15 @@
1
+ # Maik Email Client Configuration
2
+ # Place this file at ~/.config/maik/config.txt
3
+
4
+ # IMAP server settings
5
+ host = imap.example.com
6
+ user = your-email@example.com
7
+ password = your-password
8
+
9
+ # Output directory for downloaded emails
10
+ output = ~/mail
11
+
12
+ # Optional settings
13
+ # port = 993
14
+ # tls = true
15
+ # box = INBOX
package/default.ctx.js ADDED
@@ -0,0 +1,82 @@
1
+ const { loadConfig, getDb } = require('./index.js')
2
+ const path = require("path")
3
+ const fs = require("fs")
4
+ const PostalMime = require('postal-mime');
5
+ let config
6
+
7
+ module.exports = async function maik(name, args) {
8
+ if (!config) {
9
+ config = await loadConfig()
10
+ }
11
+
12
+ // should start with prefix
13
+ if (name.indexOf("mail/") !== 0) {
14
+ return
15
+ }
16
+
17
+ if (name === "mail/dir") {
18
+ return {
19
+ type: "text",
20
+ read: async () => config.output
21
+ }
22
+ }
23
+
24
+ const [_, month, uid, filename] = name.split("/")
25
+ const sourcePath = path.resolve(config.output, month, uid, "source.eml")
26
+
27
+ if (!fs.existsSync(sourcePath)) {
28
+ return
29
+ }
30
+
31
+ if (filename === "source.eml") {
32
+ return {
33
+ name,
34
+ type: "text",
35
+ source: "maik",
36
+ read: async() => fs.readFileSync(sourcePath, "utf8")
37
+ }
38
+ }
39
+
40
+ const content = fs.readFileSync(sourcePath, "utf8")
41
+
42
+ // Parse with PostalMime
43
+ const email = await PostalMime.parse(content);
44
+ let mimetype
45
+ let type
46
+ let value
47
+
48
+ if (filename === "body.txt") {
49
+ mimetype = "text/plain"
50
+ type = "text"
51
+ value = email.text
52
+ } else if (filename === "body.html"){
53
+ mimetype = "text/html"
54
+ type = "text"
55
+ value = email.html
56
+ } else {
57
+ const attachment = email.attachments.find(item => item.filename === filename)
58
+ if (attachment) {
59
+ mimetype = attachment.mimeType
60
+ value = Buffer.from(attachment.content)
61
+ if (mimetype.indexOf("text") === 0) {
62
+ type = "text"
63
+ value = value.toString("utf8")
64
+ } else if (mimetype.indexOf("image") === 0) {
65
+ type = "image"
66
+ } else {
67
+ type = "binary"
68
+ }
69
+ }
70
+ }
71
+
72
+ const result = {
73
+ name,
74
+ type,
75
+ mimetype,
76
+ source: "maik",
77
+ read: async () => value
78
+ }
79
+ fs.writeFileSync("./image.jpg", await result.read())
80
+ // console.log(result, value)
81
+ return result
82
+ }
package/index.js ADDED
@@ -0,0 +1,866 @@
1
+ const { ImapFlow } = require('imapflow');
2
+ const { mkdir, writeFile, readdir, stat, readFile, unlink } = require('fs/promises');
3
+ const { existsSync, createReadStream } = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const Database = require('better-sqlite3');
7
+ const PostalMime = require('postal-mime');
8
+
9
+ function getConfigPath() {
10
+ return path.join(os.homedir(), '.config', 'maik', 'config.txt');
11
+ }
12
+
13
+ async function loadConfig() {
14
+ const configPath = getConfigPath();
15
+ const config = {};
16
+
17
+ if (!existsSync(configPath)) {
18
+ return config;
19
+ }
20
+
21
+ try {
22
+ const content = await readFile(configPath, 'utf-8');
23
+ const lines = content.split('\n');
24
+
25
+ for (let line of lines) {
26
+ line = line.trim();
27
+
28
+ // Skip comments and empty lines
29
+ if (!line || line.startsWith('#')) {
30
+ continue;
31
+ }
32
+
33
+ const equalIndex = line.indexOf('=');
34
+ if (equalIndex > 0) {
35
+ const key = line.substring(0, equalIndex).trim().toLowerCase();
36
+ const value = line.substring(equalIndex + 1).trim();
37
+
38
+ // Map config keys to options
39
+ switch (key) {
40
+ case 'host':
41
+ config.host = value;
42
+ break;
43
+ case 'user':
44
+ case 'username':
45
+ config.user = value;
46
+ break;
47
+ case 'password':
48
+ case 'pass':
49
+ config.password = value;
50
+ break;
51
+ case 'output':
52
+ case 'dir':
53
+ case 'directory':
54
+ config.output = value;
55
+ break;
56
+ case 'port':
57
+ config.port = value;
58
+ break;
59
+ case 'tls':
60
+ config.tls = value.toLowerCase() === 'true';
61
+ break;
62
+ case 'box':
63
+ case 'mailbox':
64
+ config.box = value;
65
+ break;
66
+ }
67
+ }
68
+ }
69
+ } catch (error) {
70
+ // Ignore errors, return empty config
71
+ }
72
+
73
+ if (config.output) {
74
+ config.output = expandHome(config.output)
75
+ }
76
+
77
+ return config;
78
+ }
79
+
80
+ function expandHome(p) {
81
+ if (!p) return p;
82
+ // Only expand a leading ~ or ~/...
83
+ if (p === '~') return os.homedir();
84
+ if (p.startsWith('~/') || p.startsWith('~\\')) {
85
+ return path.join(os.homedir(), p.slice(2));
86
+ }
87
+ return p;
88
+ }
89
+
90
+ function mergeConfig(options, config) {
91
+ return {
92
+ host: options.host || config.host,
93
+ user: options.user || config.user,
94
+ password: options.password || config.password,
95
+ output: expandHome(options.output || config.output),
96
+ port: options.port || config.port || '993',
97
+ tls: options.tls !== undefined ? options.tls : (config.tls !== undefined ? config.tls : true),
98
+ box: options.box || config.box || 'INBOX',
99
+ limit: options.limit || '0',
100
+ verbose: options.verbose || false,
101
+ clean: options.clean || false
102
+ };
103
+ }
104
+
105
+ async function connectToImap(options) {
106
+ // Validate required options
107
+ if (!options.host) {
108
+ console.error('Error: host is required (set in config or pass via --host)');
109
+ process.exit(1);
110
+ }
111
+ if (!options.user) {
112
+ console.error('Error: user is required (set in config or pass via --user)');
113
+ process.exit(1);
114
+ }
115
+ if (!options.password) {
116
+ console.error('Error: password is required (set in config or pass via --password)');
117
+ process.exit(1);
118
+ }
119
+
120
+ const client = new ImapFlow({
121
+ host: options.host,
122
+ port: parseInt(options.port),
123
+ secure: options.tls,
124
+ auth: {
125
+ user: options.user,
126
+ pass: options.password
127
+ },
128
+ logger: {
129
+ debug: () => {},
130
+ info: () => {},
131
+ warn: (msg) => console.log('Warning:', msg),
132
+ error: (msg) => console.log('Error:', msg)
133
+ }
134
+ });
135
+
136
+ console.log(`Connecting to ${options.host}:${options.port}...`);
137
+ console.log(`User: ${options.user}`);
138
+
139
+ try {
140
+ await client.connect();
141
+ console.log('Connected successfully!');
142
+ return client;
143
+ } catch (error) {
144
+ console.error('\nConnection failed!');
145
+ console.error('Error details:', error.message || error);
146
+ throw error;
147
+ }
148
+ }
149
+
150
+ async function ensureDirectory(dir) {
151
+ if (!existsSync(dir)) {
152
+ await mkdir(dir, { recursive: true });
153
+ }
154
+ }
155
+
156
+ function formatDate(date) {
157
+ const year = date.getFullYear();
158
+ const month = String(date.getMonth() + 1).padStart(2, '0');
159
+ return `${year}-${month}`;
160
+ }
161
+
162
+ async function getLatestUid(outputDir) {
163
+ if (!existsSync(outputDir)) {
164
+ return 0;
165
+ }
166
+
167
+ let maxUid = 0;
168
+
169
+ try {
170
+ const dateFolders = await readdir(outputDir);
171
+
172
+ for (const folder of dateFolders) {
173
+ const folderPath = path.join(outputDir, folder);
174
+ const folderStats = await stat(folderPath);
175
+
176
+ // Skip if not a directory
177
+ if (!folderStats.isDirectory()) {
178
+ continue;
179
+ }
180
+
181
+ const files = await readdir(folderPath);
182
+
183
+ for (const file of files) {
184
+ // Extract UID from filename (format: uid.eml)
185
+ const match = file.match(/^(\d+)\.eml$/);
186
+ if (match) {
187
+ const uid = parseInt(match[1]);
188
+ if (uid > maxUid) {
189
+ maxUid = uid;
190
+ }
191
+ }
192
+ }
193
+ }
194
+ } catch (error) {
195
+ // Ignore errors, return 0
196
+ }
197
+
198
+ return maxUid;
199
+ }
200
+
201
+ async function getLatestUidNew(outputDir) {
202
+ if (!existsSync(outputDir)) {
203
+ return 0;
204
+ }
205
+
206
+ let maxUid = 0;
207
+
208
+ try {
209
+ const dateFolders = await readdir(outputDir);
210
+
211
+ for (const folder of dateFolders) {
212
+ const folderPath = path.join(outputDir, folder);
213
+ const folderStats = await stat(folderPath);
214
+
215
+ // Skip if not a directory
216
+ if (!folderStats.isDirectory()) {
217
+ continue;
218
+ }
219
+
220
+ // Look for UID directories (not .eml files anymore)
221
+ const uidDirs = await readdir(folderPath);
222
+
223
+ for (const uidDir of uidDirs) {
224
+ // Check if it's a numeric directory name (UID)
225
+ const uidNum = parseInt(uidDir);
226
+ if (!isNaN(uidNum) && uidNum > 0) {
227
+ const uidPath = path.join(folderPath, uidDir);
228
+ try {
229
+ const uidStats = await stat(uidPath);
230
+ if (uidStats.isDirectory() && uidNum > maxUid) {
231
+ maxUid = uidNum;
232
+ }
233
+ } catch {
234
+ // Ignore errors
235
+ }
236
+ }
237
+ }
238
+ }
239
+ } catch (error) {
240
+ // Ignore errors, return 0
241
+ }
242
+
243
+ return maxUid;
244
+ }
245
+
246
+ async function saveEmailSource(source, uidDir, verbose) {
247
+ // Save original source only
248
+ const sourcePath = path.join(uidDir, 'source.eml');
249
+ await writeFile(sourcePath, source);
250
+ if (verbose) console.log(` -> source.eml (${source.length} bytes)`);
251
+ }
252
+
253
+ async function extractEmailParts(uidDir, verbose) {
254
+ const sourcePath = path.join(uidDir, 'source.eml');
255
+
256
+ if (!existsSync(sourcePath)) {
257
+ return null;
258
+ }
259
+
260
+ // Read and parse the email with PostalMime
261
+ const source = await readFile(sourcePath);
262
+ const email = await PostalMime.parse(source);
263
+
264
+ // Save text body
265
+ if (email.text) {
266
+ const textPath = path.join(uidDir, 'body.txt');
267
+ await writeFile(textPath, email.text);
268
+ if (verbose) console.log(` -> body.txt (${email.text.length} chars)`);
269
+ }
270
+
271
+ // Save html body
272
+ if (email.html) {
273
+ const htmlPath = path.join(uidDir, 'body.html');
274
+ await writeFile(htmlPath, email.html);
275
+ if (verbose) console.log(` -> body.html (${email.html.length} chars)`);
276
+ }
277
+
278
+ // Save attachments
279
+ let attachmentCount = 0;
280
+ if (email.attachments && email.attachments.length > 0) {
281
+ for (const att of email.attachments) {
282
+ const filename = att.filename || `attachment-${attachmentCount + 1}`;
283
+ const attPath = path.join(uidDir, filename);
284
+
285
+ // Handle filename conflicts
286
+ let finalPath = attPath;
287
+ let counter = 1;
288
+ while (existsSync(finalPath)) {
289
+ const ext = path.extname(filename);
290
+ const base = path.basename(filename, ext);
291
+ finalPath = path.join(uidDir, `${base}-${counter}${ext}`);
292
+ counter++;
293
+ }
294
+
295
+ await writeFile(finalPath, att.content);
296
+ attachmentCount++;
297
+ if (verbose) console.log(` -> ${path.basename(finalPath)} (${att.mimeType}, ${att.content.length} bytes)`);
298
+ }
299
+ }
300
+
301
+ return {
302
+ hasText: !!email.text,
303
+ hasHtml: !!email.html,
304
+ attachmentCount,
305
+ subject: email.subject || 'No Subject'
306
+ };
307
+ }
308
+
309
+ async function downloadEmails(options) {
310
+ // Validate output directory
311
+ if (!options.output) {
312
+ console.error('Error: output directory is required (set in config or pass via --output)');
313
+ process.exit(1);
314
+ }
315
+
316
+ const client = await connectToImap(options);
317
+
318
+ try {
319
+ // Select mailbox
320
+ await client.mailboxOpen(options.box);
321
+ console.log(`Opened mailbox: ${options.box}`);
322
+
323
+ // Get total message count
324
+ const status = await client.status(options.box, { messages: true });
325
+ const totalMessages = status.messages || 0;
326
+ console.log(`Total messages in ${options.box}: ${totalMessages}`);
327
+
328
+ if (totalMessages === 0) {
329
+ console.log('No messages to download.');
330
+ return;
331
+ }
332
+
333
+ // Scan for latest UID
334
+ console.log('Scanning for existing emails...');
335
+ const latestUid = await getLatestUidNew(options.output);
336
+
337
+ if (latestUid > 0) {
338
+ console.log(`Latest downloaded UID: ${latestUid}`);
339
+ } else {
340
+ console.log('No existing emails found, will download all');
341
+ }
342
+
343
+ const limit = parseInt(options.limit);
344
+ let processed = 0;
345
+ let saved = 0;
346
+ let lastValidDate = null;
347
+ const now = new Date();
348
+
349
+ // Create output directory
350
+ await ensureDirectory(options.output);
351
+
352
+ // Determine fetch range - only fetch UIDs greater than latestUid
353
+ const fetchRange = latestUid > 0 ? `${latestUid + 1}:*` : '1:*';
354
+
355
+ // Fetch messages
356
+ for await (const message of client.fetch(fetchRange, {
357
+ envelope: true,
358
+ source: true,
359
+ internalDate: true
360
+ }, {
361
+ uid: true
362
+ })) {
363
+ try {
364
+ processed++;
365
+
366
+ // Get date from internal date or envelope
367
+ let emailDate = message.internalDate || new Date(message.envelope.date);
368
+
369
+ if (isNaN(emailDate.getTime())) {
370
+ console.log(`Skipping message ${processed} - invalid date`);
371
+ continue;
372
+ }
373
+
374
+ // Check if date is in the future
375
+ if (emailDate > now) {
376
+ if (lastValidDate) {
377
+ if (options.verbose) {
378
+ console.log(` Date ${emailDate.toISOString()} is in the future, using ${lastValidDate.toISOString()}`);
379
+ }
380
+ emailDate = lastValidDate;
381
+ } else {
382
+ // If no last valid date, use current date
383
+ if (options.verbose) {
384
+ console.log(` Date is in the future, using current date`);
385
+ }
386
+ emailDate = now;
387
+ }
388
+ } else {
389
+ // Update last valid date
390
+ lastValidDate = emailDate;
391
+ }
392
+
393
+ const dateFolder = formatDate(emailDate);
394
+ const datePath = path.join(options.output, dateFolder);
395
+
396
+ // Create date folder
397
+ await ensureDirectory(datePath);
398
+
399
+ // Create UID directory
400
+ const uid = message.uid;
401
+ const uidDir = path.join(datePath, String(uid));
402
+ await ensureDirectory(uidDir);
403
+
404
+ // Save source.eml only
405
+ const subject = message.envelope.subject || 'No Subject';
406
+ await saveEmailSource(message.source, uidDir, options.verbose);
407
+
408
+ saved++;
409
+ console.log(`Saved [${saved}]: ${dateFolder}/${uid} - "${subject.substring(0, 50)}"`);
410
+
411
+ // Check limit
412
+ if (limit > 0 && saved >= limit) {
413
+ console.log(`\nReached limit of ${limit} emails`);
414
+ break;
415
+ }
416
+
417
+ } catch (error) {
418
+ console.error(`Error processing message:`, error);
419
+ }
420
+ }
421
+
422
+ console.log(`\nDownload complete!`);
423
+ console.log(`Downloaded: ${saved} new messages`);
424
+ if (latestUid > 0 && saved === 0) {
425
+ console.log('No new emails to download (already up to date)');
426
+ }
427
+
428
+ } finally {
429
+ await client.logout();
430
+ console.log('\nDisconnected from server.');
431
+ }
432
+ }
433
+
434
+ // Email parsing utilities using PostalMime
435
+ async function parseEmailHeadersFast(filePath) {
436
+ // Read the email file
437
+ const emailContent = await readFile(filePath, 'utf-8');
438
+
439
+ // Parse with PostalMime
440
+ const email = await PostalMime.parse(emailContent);
441
+
442
+ // Extract sender info
443
+ const sender = email.from ? (typeof email.from === 'object' && 'address' in email.from ? email.from.address : '') : '';
444
+ const sender_name = email.from && typeof email.from === 'object' && 'name' in email.from ? email.from.name : '';
445
+
446
+ // Extract recipients
447
+ const to = email.to
448
+ ? (Array.isArray(email.to)
449
+ ? email.to.map(addr => typeof addr === 'object' && 'address' in addr ? addr.address : '').join(', ')
450
+ : '')
451
+ : '';
452
+
453
+ const cc = email.cc
454
+ ? (Array.isArray(email.cc)
455
+ ? email.cc.map(addr => typeof addr === 'object' && 'address' in addr ? addr.address : '').join(', ')
456
+ : '')
457
+ : '';
458
+
459
+ const bcc = email.bcc
460
+ ? (Array.isArray(email.bcc)
461
+ ? email.bcc.map(addr => typeof addr === 'object' && 'address' in addr ? addr.address : '').join(', ')
462
+ : '')
463
+ : '';
464
+
465
+ const reply_to = email.replyTo
466
+ ? (Array.isArray(email.replyTo)
467
+ ? email.replyTo.map(addr => typeof addr === 'object' && 'address' in addr ? addr.address : '').join(', ')
468
+ : (typeof email.replyTo === 'object' && 'address' in email.replyTo ? email.replyTo.address : ''))
469
+ : '';
470
+
471
+ // Extract attachments (filename + mimeType)
472
+ const attachments = email.attachments
473
+ ? email.attachments.map(att => `${att.filename || 'unnamed'}:${att.mimeType}`).join(', ')
474
+ : '';
475
+
476
+ // Extract received-spf from headers
477
+ const received_spf_header = email.headers.find(h => h.key === 'received-spf');
478
+ const received_spf = received_spf_header ? received_spf_header.value : '';
479
+
480
+ return {
481
+ date: email.date || '',
482
+ sender,
483
+ sender_name,
484
+ to,
485
+ subject: email.subject || '',
486
+ message_id: email.messageId || '',
487
+ in_reply_to: email.inReplyTo || '',
488
+ refs: email.references || '',
489
+ cc,
490
+ bcc,
491
+ reply_to,
492
+ delivered_to: email.deliveredTo || '',
493
+ attachments,
494
+ received_spf
495
+ };
496
+ }
497
+
498
+ function getDb(outputDir) {
499
+ const dbPath = path.join(outputDir, 'index.sqlite');
500
+ const db = new Database(dbPath);
501
+ db.pragma('journal_mode = WAL');
502
+ return db;
503
+ }
504
+
505
+ async function indexEmails(options) {
506
+ // Validate output directory
507
+ if (!options.output) {
508
+ console.error('Error: output directory is required (set in config or pass via --output)');
509
+ process.exit(1);
510
+ }
511
+
512
+ const outputDir = options.output;
513
+ const verbose = options.verbose;
514
+ const clean = options.clean;
515
+
516
+ if (!existsSync(outputDir)) {
517
+ console.error(`Error: Directory '${outputDir}' does not exist`);
518
+ process.exit(1);
519
+ }
520
+
521
+ const dbPath = path.join(outputDir, 'index.sqlite');
522
+
523
+ // Handle --clean option: remove existing database
524
+ if (clean && existsSync(dbPath)) {
525
+ console.log('Clean mode: removing existing index database...');
526
+
527
+ // Remove main database file
528
+ await unlink(dbPath);
529
+
530
+ // Remove WAL and SHM files if they exist
531
+ const walPath = dbPath + '-wal';
532
+ const shmPath = dbPath + '-shm';
533
+
534
+ if (existsSync(walPath)) {
535
+ await unlink(walPath);
536
+ }
537
+ if (existsSync(shmPath)) {
538
+ await unlink(shmPath);
539
+ }
540
+
541
+ console.log('Database removed successfully.\n');
542
+ }
543
+
544
+ const db = getDb(outputDir);
545
+
546
+ // Create table if not exists
547
+ db.exec(`
548
+ CREATE TABLE IF NOT EXISTS emails (
549
+ uid INTEGER PRIMARY KEY,
550
+ date INTEGER,
551
+ sender TEXT,
552
+ sender_name TEXT,
553
+ "to" TEXT,
554
+ subject TEXT,
555
+ size INTEGER,
556
+ message_id TEXT,
557
+ in_reply_to TEXT,
558
+ refs TEXT,
559
+ cc TEXT,
560
+ bcc TEXT,
561
+ reply_to TEXT,
562
+ delivered_to TEXT,
563
+ attachments TEXT,
564
+ received_spf TEXT
565
+ )
566
+ `);
567
+
568
+ // Create index on frequently queried columns
569
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_emails_date ON emails(date)`);
570
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_emails_sender ON emails(sender)`);
571
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_emails_message_id ON emails(message_id)`);
572
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_emails_in_reply_to ON emails(in_reply_to)`);
573
+
574
+ // Get existing UIDs
575
+ const existingRows = db.prepare('SELECT uid FROM emails').all();
576
+ const existingUids = new Set(existingRows.map(row => row.uid));
577
+
578
+ console.log(`Database: ${dbPath}`);
579
+ console.log(`Already indexed: ${existingUids.size} emails`);
580
+
581
+ let totalFiles = 0;
582
+ let newFiles = 0;
583
+ let skippedFiles = 0;
584
+ let errorFiles = 0;
585
+
586
+ // Prepare insert statement
587
+ const insertStmt = db.prepare(`
588
+ INSERT INTO emails (
589
+ uid, date, sender, sender_name, "to", subject, size,
590
+ message_id, in_reply_to, refs, cc, bcc, reply_to,
591
+ delivered_to, attachments, received_spf
592
+ ) VALUES (
593
+ @uid, @date, @sender, @sender_name, @to, @subject, @size,
594
+ @message_id, @in_reply_to, @refs, @cc, @bcc, @reply_to,
595
+ @delivered_to, @attachments, @received_spf
596
+ )
597
+ `);
598
+
599
+ // Use transaction for batch inserts
600
+ const insertMany = db.transaction((emails) => {
601
+ for (const email of emails) {
602
+ insertStmt.run(email);
603
+ }
604
+ });
605
+
606
+ // Collect emails to insert for batch processing
607
+ let batch = [];
608
+
609
+ try {
610
+ const dateFolders = (await readdir(outputDir)).sort();
611
+
612
+ for (const folder of dateFolders) {
613
+ const folderPath = path.join(outputDir, folder);
614
+ const folderStats = await stat(folderPath);
615
+
616
+ // Skip if not a directory
617
+ if (!folderStats.isDirectory()) {
618
+ continue;
619
+ }
620
+
621
+ // Look for UID directories
622
+ const uidDirs = (await readdir(folderPath)).sort();
623
+
624
+ for (const uidDir of uidDirs) {
625
+ // Check if it's a numeric directory name (UID)
626
+ const uid = parseInt(uidDir);
627
+ if (isNaN(uid) || uid <= 0) {
628
+ continue;
629
+ }
630
+
631
+ const uidPath = path.join(folderPath, uidDir);
632
+ let uidStats;
633
+ try {
634
+ uidStats = await stat(uidPath);
635
+ } catch {
636
+ continue;
637
+ }
638
+
639
+ // Skip if not a directory
640
+ if (!uidStats.isDirectory()) {
641
+ continue;
642
+ }
643
+
644
+ // Look for source.eml inside the UID directory
645
+ const sourcePath = path.join(uidPath, 'source.eml');
646
+ if (!existsSync(sourcePath)) {
647
+ if (verbose) {
648
+ console.log(`Skipping ${folder}/${uidDir} - no source.eml found`);
649
+ }
650
+ skippedFiles++;
651
+ continue;
652
+ }
653
+
654
+ totalFiles++;
655
+
656
+ // Check if already indexed
657
+ if (existingUids.has(uid)) {
658
+ if (verbose) {
659
+ console.log(`Skipping ${folder}/${uidDir} - already indexed`);
660
+ }
661
+ continue;
662
+ }
663
+
664
+ // Parse the email file
665
+ try {
666
+ const fileStats = await stat(sourcePath);
667
+ const headers = await parseEmailHeadersFast(sourcePath);
668
+
669
+ // Convert date string to Unix timestamp
670
+ let dateTimestamp = 0;
671
+ if (headers.date) {
672
+ const parsedDate = new Date(headers.date);
673
+ if (!isNaN(parsedDate.getTime())) {
674
+ dateTimestamp = Math.floor(parsedDate.getTime() / 1000);
675
+ }
676
+ }
677
+
678
+ // Collect for batch insert
679
+ batch.push({
680
+ uid: uid,
681
+ date: dateTimestamp,
682
+ sender: headers.sender,
683
+ sender_name: headers.sender_name,
684
+ to: headers.to,
685
+ subject: headers.subject,
686
+ size: fileStats.size,
687
+ message_id: headers.message_id,
688
+ in_reply_to: headers.in_reply_to,
689
+ refs: headers.refs,
690
+ cc: headers.cc,
691
+ bcc: headers.bcc,
692
+ reply_to: headers.reply_to,
693
+ delivered_to: headers.delivered_to,
694
+ attachments: headers.attachments,
695
+ received_spf: headers.received_spf
696
+ });
697
+
698
+ newFiles++;
699
+
700
+ // Batch insert every 1000 emails
701
+ if (newFiles % 1000 === 0) {
702
+ console.log(`Parsed ${newFiles} new emails..., inserting`);
703
+ insertMany(batch);
704
+ batch = [];
705
+ }
706
+ } catch (error) {
707
+ console.error(`Error parsing ${folder}/${uidDir}/source.eml:`, error);
708
+ errorFiles++;
709
+ }
710
+ }
711
+ }
712
+
713
+ // Insert remaining emails
714
+ if (batch.length > 0) {
715
+ insertMany(batch);
716
+ }
717
+
718
+ // Get final count
719
+ console.log(`\nIndexing complete!`);
720
+ console.log(`Total .eml files found: ${totalFiles}`);
721
+ console.log(`New emails indexed: ${newFiles}`);
722
+ console.log(`Skipped (already indexed): ${totalFiles - newFiles - errorFiles}`);
723
+ if (errorFiles > 0) {
724
+ console.log(`Errors: ${errorFiles}`);
725
+ }
726
+
727
+ } finally {
728
+ db.close();
729
+ }
730
+ }
731
+
732
+ async function parseEmails(options) {
733
+ // Validate output directory
734
+ if (!options.output) {
735
+ console.error('Error: output directory is required (set in config or pass via --output)');
736
+ process.exit(1);
737
+ }
738
+
739
+ const outputDir = options.output;
740
+ const verbose = options.verbose;
741
+
742
+ if (!existsSync(outputDir)) {
743
+ console.error(`Error: Directory '${outputDir}' does not exist`);
744
+ process.exit(1);
745
+ }
746
+
747
+ console.log(`Parsing emails in: ${outputDir}`);
748
+
749
+ let totalDirs = 0;
750
+ let parsedDirs = 0;
751
+ let skippedDirs = 0;
752
+ let errorDirs = 0;
753
+ let totalAttachments = 0;
754
+ let totalText = 0;
755
+ let totalHtml = 0;
756
+
757
+ try {
758
+ const dateFolders = (await readdir(outputDir)).sort();
759
+
760
+ for (const folder of dateFolders) {
761
+ const folderPath = path.join(outputDir, folder);
762
+ const folderStats = await stat(folderPath);
763
+
764
+ // Skip if not a directory
765
+ if (!folderStats.isDirectory()) {
766
+ continue;
767
+ }
768
+
769
+ // Look for UID directories
770
+ const uidDirs = (await readdir(folderPath)).sort();
771
+
772
+ for (const uidDir of uidDirs) {
773
+ // Check if it's a numeric directory name (UID)
774
+ const uid = parseInt(uidDir);
775
+ if (isNaN(uid) || uid <= 0) {
776
+ continue;
777
+ }
778
+
779
+ const uidPath = path.join(folderPath, uidDir);
780
+ let uidStats;
781
+ try {
782
+ uidStats = await stat(uidPath);
783
+ } catch {
784
+ continue;
785
+ }
786
+
787
+ // Skip if not a directory
788
+ if (!uidStats.isDirectory()) {
789
+ continue;
790
+ }
791
+
792
+ // Check if source.eml exists
793
+ const sourcePath = path.join(uidPath, 'source.eml');
794
+ if (!existsSync(sourcePath)) {
795
+ if (verbose) {
796
+ console.log(`Skipping ${folder}/${uidDir} - no source.eml found`);
797
+ }
798
+ skippedDirs++;
799
+ continue;
800
+ }
801
+
802
+ // Check if already parsed (has body.txt or body.html)
803
+ const bodyTxtPath = path.join(uidPath, 'body.txt');
804
+ const bodyHtmlPath = path.join(uidPath, 'body.html');
805
+ if (existsSync(bodyTxtPath) || existsSync(bodyHtmlPath)) {
806
+ if (verbose) {
807
+ console.log(`Skipping ${folder}/${uidDir} - already parsed`);
808
+ }
809
+ continue;
810
+ }
811
+
812
+ totalDirs++;
813
+
814
+ // Parse the email
815
+ try {
816
+ const result = await extractEmailParts(uidPath, verbose);
817
+
818
+ if (result) {
819
+ parsedDirs++;
820
+ if (result.hasText) totalText++;
821
+ if (result.hasHtml) totalHtml++;
822
+ totalAttachments += result.attachmentCount;
823
+
824
+ console.log(`Parsed [${parsedDirs}]: ${folder}/${uidDir} - "${result.subject.substring(0, 50)}"`);
825
+ }
826
+ } catch (error) {
827
+ console.error(`Error parsing ${folder}/${uidDir}:`, error);
828
+ errorDirs++;
829
+ }
830
+ }
831
+ }
832
+
833
+ console.log(`\nParsing complete!`);
834
+ console.log(`Emails parsed: ${parsedDirs}`);
835
+ console.log(` - with text body: ${totalText}`);
836
+ console.log(` - with html body: ${totalHtml}`);
837
+ console.log(` - total attachments: ${totalAttachments}`);
838
+ if (skippedDirs > 0) {
839
+ console.log(`Skipped (no source.eml): ${skippedDirs}`);
840
+ }
841
+ if (errorDirs > 0) {
842
+ console.log(`Errors: ${errorDirs}`);
843
+ }
844
+
845
+ } catch (error) {
846
+ console.error('Error parsing emails:', error);
847
+ process.exit(1);
848
+ }
849
+ }
850
+
851
+ module.exports = {
852
+ loadConfig,
853
+ mergeConfig,
854
+ connectToImap,
855
+ ensureDirectory,
856
+ formatDate,
857
+ getLatestUid,
858
+ getLatestUidNew,
859
+ saveEmailSource,
860
+ extractEmailParts,
861
+ downloadEmails,
862
+ parseEmails,
863
+ indexEmails,
864
+ parseEmailHeadersFast,
865
+ getDb
866
+ };
package/mail.prompt ADDED
@@ -0,0 +1,25 @@
1
+ @rf
2
+ @mistral_ocr
3
+
4
+ @{ sqlite | curry filename=~/mail/index.sqlite $name=mail_query }
5
+ You are mail assistant.
6
+
7
+ <emails-table-schema>
8
+ @{| proc sqlite filename=~/mail/index.sqlite text="PRAGMA table_info(emails);" }
9
+ </emails-table-schema>
10
+
11
+ Always transform date to datetime(date, 'unixepoch')
12
+
13
+ You can read text of email from file
14
+ mail/YYYY-MM/<uid>/body.txt
15
+ or
16
+ mail/YYYY-MM/<uid>/body.html
17
+
18
+ attachment are accessable by path
19
+ mail/YYYY-MM/<uid>/<attachmentname.ext>
20
+
21
+ original .eml files are located at
22
+ @{mail/dir}/YYYY-MM/<uid>/source.eml
23
+
24
+
25
+ the database is big, always add limit to query not to explode the context
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@iovdin/maik",
3
+ "version": "1.0.0",
4
+ "description": "Command-line IMAP email client that downloads emails from your server and indexes them locally in SQLite for fast offline search and querying.",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "maik": "cli.js"
8
+ },
9
+ "license": "MIT",
10
+ "author": "Ilya Ovdin &lt;iovdin@gmail.com&gt;",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/iovdin/maik.git"
14
+ },
15
+ "homepage": "https://github.com/iovdin/maik#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/iovdin/maik/issues"
18
+ },
19
+ "keywords": [
20
+ "imap",
21
+ "email",
22
+ "cli",
23
+ "command-line",
24
+ "sqlite",
25
+ "offline",
26
+ "mail",
27
+ "index",
28
+ "search",
29
+ "download"
30
+ ],
31
+ "engines": {
32
+ "node": ">=18.0.0"
33
+ },
34
+ "type": "commonjs",
35
+ "exports": {
36
+ "./middleware": "./default.ctx.js"
37
+ },
38
+ "devDependencies": {
39
+ "@types/better-sqlite3": "^7.6.11"
40
+ },
41
+ "dependencies": {
42
+ "better-sqlite3": "^11.7.0",
43
+ "commander": "^14.0.3",
44
+ "imapflow": "^1.2.9",
45
+ "postal-mime": "^2.7.3"
46
+ }
47
+ }