@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.
- package/.github/workflows/publish.yml +27 -0
- package/README.md +256 -0
- package/cli.js +68 -0
- package/config.txt.example +15 -0
- package/default.ctx.js +82 -0
- package/index.js +866 -0
- package/mail.prompt +25 -0
- package/package.json +47 -0
|
@@ -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 <iovdin@gmail.com>",
|
|
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
|
+
}
|