stellamail 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,95 @@
1
+ 'use strict';
2
+
3
+ const { getAccount } = require('../config');
4
+ const { withImap } = require('../imap');
5
+ const { chalk } = require('../format');
6
+
7
+ const FLAG_MAP = {
8
+ seen: '\\Seen',
9
+ flagged: '\\Flagged',
10
+ answered: '\\Answered',
11
+ draft: '\\Draft'
12
+ };
13
+
14
+ async function run(opts, config) {
15
+ const id = opts._positional && opts._positional[0];
16
+ if (!id) {
17
+ console.error(chalk.red('❌ Usage: stellamail flag <id> --account <name> --add|--remove <flag>'));
18
+ process.exit(1);
19
+ }
20
+ if (!opts.account) {
21
+ console.error(chalk.red('❌ --account is required for flag'));
22
+ process.exit(1);
23
+ }
24
+ if (!opts.add && !opts.remove) {
25
+ console.error(chalk.red('❌ --add <flag> or --remove <flag> is required'));
26
+ console.error(chalk.gray(' Valid flags: seen, flagged, answered, draft'));
27
+ process.exit(1);
28
+ }
29
+
30
+ const acctConfig = getAccount(config, opts.account);
31
+ const folder = opts.folder || 'INBOX';
32
+ const seq = parseInt(id, 10);
33
+ const isJson = opts.output === 'json';
34
+
35
+ const flagName = opts.add || opts.remove;
36
+ const imapFlag = FLAG_MAP[flagName.toLowerCase()];
37
+ if (!imapFlag) {
38
+ console.error(chalk.red(`❌ Unknown flag: "${flagName}"`));
39
+ console.error(chalk.gray(' Valid flags: seen, flagged, answered, draft'));
40
+ process.exit(1);
41
+ }
42
+
43
+ const adding = !!opts.add;
44
+
45
+ await withImap(acctConfig, config, async (client) => {
46
+ const mailbox = await client.getMailboxLock(folder);
47
+ try {
48
+ if (adding) {
49
+ await client.messageFlagsAdd(String(seq), [imapFlag]);
50
+ } else {
51
+ await client.messageFlagsRemove(String(seq), [imapFlag]);
52
+ }
53
+
54
+ if (isJson) {
55
+ console.log(JSON.stringify({
56
+ status: 'updated',
57
+ id: seq,
58
+ folder,
59
+ action: adding ? 'added' : 'removed',
60
+ flag: flagName.toLowerCase(),
61
+ account: opts.account
62
+ }, null, 2));
63
+ } else {
64
+ const action = adding ? 'Added' : 'Removed';
65
+ console.log(chalk.green(`✅ ${action} flag "${flagName}" on message #${seq}`));
66
+ }
67
+ } finally {
68
+ mailbox.release();
69
+ }
70
+ });
71
+ }
72
+
73
+ const help = `
74
+ stellamail flag — Toggle flags on a message
75
+
76
+ USAGE:
77
+ stellamail flag <id> --account <name> --add|--remove <flag>
78
+
79
+ REQUIRED:
80
+ <id> Message sequence number
81
+ --account <name> Account name from config
82
+ --add <flag> Add a flag (seen, flagged, answered, draft)
83
+ --remove <flag> Remove a flag
84
+
85
+ OPTIONAL:
86
+ --folder <name> Folder name (default INBOX)
87
+ --output json Output as JSON
88
+ --config <path> Path to config file
89
+
90
+ EXAMPLES:
91
+ stellamail flag 142 --account work --add flagged
92
+ stellamail flag 142 --account work --remove seen
93
+ `;
94
+
95
+ module.exports = { run, help };
@@ -0,0 +1,84 @@
1
+ 'use strict';
2
+
3
+ const { getAccount } = require('../config');
4
+ const { withImap } = require('../imap');
5
+ const { chalk } = require('../format');
6
+
7
+ async function run(opts, config) {
8
+ if (!opts.account) {
9
+ console.error(chalk.red('❌ --account is required for folders'));
10
+ process.exit(1);
11
+ }
12
+
13
+ const acctConfig = getAccount(config, opts.account);
14
+ const isJson = opts.output === 'json';
15
+
16
+ await withImap(acctConfig, config, async (client) => {
17
+ const folders = await client.list();
18
+
19
+ if (isJson) {
20
+ const jsonFolders = [];
21
+ for (const f of folders) {
22
+ let messageCount = null;
23
+ try {
24
+ const status = await client.status(f.path, { messages: true });
25
+ messageCount = status.messages;
26
+ } catch (e) {}
27
+ jsonFolders.push({
28
+ path: f.path,
29
+ name: f.name || f.path,
30
+ messages: messageCount
31
+ });
32
+ }
33
+ console.log(JSON.stringify(jsonFolders, null, 2));
34
+ return;
35
+ }
36
+
37
+ console.log(chalk.cyan(`📁 Folders — ${opts.account}`));
38
+
39
+ const spamTrash = ['spam', 'trash', 'junk', '[gmail]/spam', '[gmail]/trash'];
40
+
41
+ for (let i = 0; i < folders.length; i++) {
42
+ const f = folders[i];
43
+ const isLast = i === folders.length - 1;
44
+ const prefix = isLast ? '└── ' : '├── ';
45
+
46
+ let countStr = '';
47
+ try {
48
+ const status = await client.status(f.path, { messages: true });
49
+ countStr = ` (${chalk.yellow(status.messages)})`;
50
+ } catch (e) {
51
+ countStr = '';
52
+ }
53
+
54
+ const folderPath = f.path;
55
+ const isInbox = folderPath.toUpperCase() === 'INBOX';
56
+ const isSpamTrash = spamTrash.includes(folderPath.toLowerCase());
57
+
58
+ let folderDisplay;
59
+ if (isInbox) {
60
+ folderDisplay = chalk.cyan.bold(folderPath);
61
+ } else if (isSpamTrash) {
62
+ folderDisplay = chalk.red(folderPath);
63
+ } else {
64
+ folderDisplay = chalk.cyan(folderPath);
65
+ }
66
+
67
+ console.log(`${chalk.gray(prefix)}${folderDisplay}${countStr}`);
68
+ }
69
+ });
70
+ }
71
+
72
+ const help = `
73
+ stellamail folders — List mailbox folders
74
+
75
+ USAGE:
76
+ stellamail folders --account <name> [options]
77
+
78
+ OPTIONS:
79
+ --account <name> Account name (required)
80
+ --output json Output as JSON
81
+ --config <path> Path to config file
82
+ `;
83
+
84
+ module.exports = { run, help };
@@ -0,0 +1,237 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const { simpleParser } = require('mailparser');
5
+ const { getAccount } = require('../config');
6
+ const { withImap } = require('../imap');
7
+ const { getSignature } = require('../signature');
8
+ const { validateAttachments, checkDuplicate } = require('../safety');
9
+ const { logSend } = require('../logger');
10
+ const { createTransport } = require('../smtp');
11
+ const { chalk } = require('../format');
12
+
13
+ async function run(opts, config) {
14
+ const id = opts._positional && opts._positional[0];
15
+ if (!id) {
16
+ console.error(chalk.red('❌ Usage: stellamail forward <id> --account <name> --to <email>'));
17
+ process.exit(1);
18
+ }
19
+ if (!opts.account) {
20
+ console.error(chalk.red('❌ --account is required for forward'));
21
+ process.exit(1);
22
+ }
23
+ if (!opts.to) {
24
+ console.error(chalk.red('❌ --to is required for forward'));
25
+ process.exit(1);
26
+ }
27
+
28
+ // Handle body-file
29
+ if (opts['body-file']) {
30
+ if (!fs.existsSync(opts['body-file'])) {
31
+ console.error(chalk.red(`❌ Body file not found: ${opts['body-file']}`));
32
+ process.exit(1);
33
+ }
34
+ opts.body = fs.readFileSync(opts['body-file'], 'utf8');
35
+ }
36
+
37
+ const acctConfig = getAccount(config, opts.account);
38
+ const folder = opts.folder || 'INBOX';
39
+ const seq = parseInt(id, 10);
40
+ const isJson = opts.output === 'json';
41
+
42
+ // Fetch original message via IMAP
43
+ const original = await withImap(acctConfig, config, async (client) => {
44
+ const mailbox = await client.getMailboxLock(folder);
45
+ try {
46
+ const msg = await client.fetchOne(String(seq), {
47
+ source: true,
48
+ envelope: true
49
+ });
50
+ return simpleParser(msg.source);
51
+ } finally {
52
+ mailbox.release();
53
+ }
54
+ });
55
+
56
+ // Build forward subject
57
+ const origSubject = original.subject || '';
58
+ const subject = /^fwd:/i.test(origSubject) ? origSubject : `Fwd: ${origSubject}`;
59
+ opts.subject = subject;
60
+
61
+ // Build forwarded body
62
+ const isHtml = !!opts.html;
63
+ const origFrom = original.from ? original.from.text : '';
64
+ const origTo = original.to ? original.to.text : '';
65
+ const origDate = original.date ? original.date.toISOString() : '';
66
+ const origText = original.text || original.html || '';
67
+
68
+ let fullBody;
69
+ const separator = '---------- Forwarded message ----------';
70
+ const fwdHeader = `From: ${origFrom}\nDate: ${origDate}\nSubject: ${origSubject}\nTo: ${origTo}`;
71
+
72
+ if (isHtml) {
73
+ const coverMsg = opts.body || '';
74
+ fullBody = coverMsg + '<br><br>' + separator + '<br>' +
75
+ fwdHeader.replace(/\n/g, '<br>') + '<br><br>' +
76
+ (original.html || origText);
77
+ } else {
78
+ const coverMsg = opts.body || '';
79
+ fullBody = coverMsg + '\n\n' + separator + '\n' +
80
+ fwdHeader + '\n\n' + origText;
81
+ }
82
+
83
+ // Signature
84
+ const signature = opts['no-signature'] ? '' : getSignature(acctConfig, config, isHtml);
85
+ const bodyWithSig = fullBody + signature;
86
+
87
+ // Gather attachments: original + additional
88
+ const origAttachments = (original.attachments || []).map(att => ({
89
+ filename: att.filename || 'attachment',
90
+ content: att.content,
91
+ contentType: att.contentType
92
+ }));
93
+
94
+ if (!opts.attachments) opts.attachments = [];
95
+ const additionalAttachments = opts.attachments.length
96
+ ? validateAttachments(opts.attachments, config)
97
+ : [];
98
+ const allAttachments = origAttachments.concat(additionalAttachments);
99
+
100
+ // Duplicate check
101
+ let lockFile = null;
102
+ if (!opts['no-duplicate-check']) {
103
+ const dup = checkDuplicate(opts, config);
104
+ lockFile = dup.lockFile;
105
+ }
106
+
107
+ // Build mail options
108
+ const mailOptions = {
109
+ from: acctConfig.from,
110
+ to: opts.to,
111
+ subject,
112
+ attachments: allAttachments
113
+ };
114
+ if (opts.cc) mailOptions.cc = opts.cc;
115
+ if (isHtml) {
116
+ mailOptions.html = bodyWithSig;
117
+ } else {
118
+ mailOptions.text = bodyWithSig;
119
+ }
120
+
121
+ // Dry run
122
+ if (opts['dry-run']) {
123
+ if (isJson) {
124
+ console.log(JSON.stringify({
125
+ dryRun: true,
126
+ from: mailOptions.from,
127
+ to: mailOptions.to,
128
+ cc: mailOptions.cc || null,
129
+ subject: mailOptions.subject,
130
+ format: isHtml ? 'html' : 'text',
131
+ attachments: allAttachments.map(a => a.filename),
132
+ bodyPreview: bodyWithSig.slice(0, 200)
133
+ }, null, 2));
134
+ } else {
135
+ console.log('🧪 DRY RUN — Forward would be sent:');
136
+ console.log(` ${chalk.cyan('From:')} ${mailOptions.from}`);
137
+ console.log(` ${chalk.cyan('To:')} ${mailOptions.to}`);
138
+ if (opts.cc) console.log(` ${chalk.cyan('CC:')} ${opts.cc}`);
139
+ console.log(` ${chalk.cyan('Subject:')} ${subject}`);
140
+ console.log(` ${chalk.cyan('Format:')} ${chalk.yellow(isHtml ? 'HTML' : 'Plain text')}`);
141
+ console.log(` ${chalk.cyan('Attachments:')} ${allAttachments.length ? allAttachments.map(a => a.filename).join(', ') : 'none'}`);
142
+ console.log(` ${chalk.cyan('Body preview:')}`);
143
+ console.log(chalk.gray(' ' + bodyWithSig.slice(0, 200).replace(/\n/g, '\n ') + (bodyWithSig.length > 200 ? '...' : '')));
144
+ }
145
+ return;
146
+ }
147
+
148
+ // Write lock BEFORE sending
149
+ if (lockFile) {
150
+ fs.writeFileSync(lockFile, String(Math.floor(Date.now() / 1000)));
151
+ }
152
+
153
+ const transporter = createTransport(acctConfig, config);
154
+
155
+ try {
156
+ await transporter.verify();
157
+ if (!isJson) console.log(chalk.green('✅ SMTP connection verified'));
158
+ } catch (e) {
159
+ if (lockFile && fs.existsSync(lockFile)) fs.unlinkSync(lockFile);
160
+ console.error(chalk.red('❌ SMTP connection failed: ' + e.message));
161
+ process.exit(1);
162
+ }
163
+
164
+ try {
165
+ const info = await transporter.sendMail(mailOptions);
166
+
167
+ logSend({
168
+ timestamp: new Date().toISOString(),
169
+ account: opts.account,
170
+ to: opts.to,
171
+ cc: opts.cc || null,
172
+ subject,
173
+ attachments: allAttachments.map(a => a.filename),
174
+ messageId: info.messageId,
175
+ type: 'forward',
176
+ status: 'sent'
177
+ }, config);
178
+
179
+ if (isJson) {
180
+ console.log(JSON.stringify({
181
+ status: 'sent',
182
+ messageId: info.messageId,
183
+ to: opts.to,
184
+ cc: opts.cc || null,
185
+ subject,
186
+ type: 'forward'
187
+ }, null, 2));
188
+ } else {
189
+ console.log(chalk.green('✅ Forward sent successfully!'));
190
+ console.log(` ${chalk.cyan('To:')} ${opts.to}`);
191
+ if (opts.cc) console.log(` ${chalk.cyan('CC:')} ${opts.cc}`);
192
+ console.log(` ${chalk.cyan('Subject:')} ${subject}`);
193
+ console.log(` ${chalk.cyan('Attachments:')} ${allAttachments.length ? allAttachments.map(a => a.filename).join(', ') : 'none'}`);
194
+ console.log(` ${chalk.cyan('Message ID:')} ${info.messageId}`);
195
+ }
196
+ } catch (e) {
197
+ logSend({
198
+ timestamp: new Date().toISOString(),
199
+ account: opts.account,
200
+ to: opts.to,
201
+ subject,
202
+ type: 'forward',
203
+ status: 'failed',
204
+ error: e.message
205
+ }, config);
206
+
207
+ console.error(chalk.red('❌ Forward FAILED: ' + e.message));
208
+ process.exit(1);
209
+ }
210
+ }
211
+
212
+ const help = `
213
+ stellamail forward — Forward an email
214
+
215
+ USAGE:
216
+ stellamail forward <id> --account <name> --to <email> [options]
217
+
218
+ REQUIRED:
219
+ <id> Message sequence number
220
+ --account <name> Account name from config
221
+ --to <email> Recipient email address
222
+
223
+ OPTIONAL:
224
+ --body <text> Cover message
225
+ --body-file <path> Read cover message from a file
226
+ --folder <name> Folder name (default INBOX)
227
+ --cc <email> CC recipient
228
+ --attachment <path> Additional attachment (repeatable)
229
+ --html Treat body as HTML
230
+ --no-signature Skip auto-appending signature
231
+ --no-duplicate-check Skip duplicate blocking
232
+ --dry-run Preview without sending
233
+ --output json Output as JSON
234
+ --config <path> Path to config file
235
+ `;
236
+
237
+ module.exports = { run, help };
@@ -0,0 +1,135 @@
1
+ 'use strict';
2
+
3
+ const { getAccount } = require('../config');
4
+ const { withImap } = require('../imap');
5
+ const { formatDate, truncate, messageFlags, makeTable, chalk } = require('../format');
6
+
7
+ async function run(opts, config) {
8
+ if (!opts.account) {
9
+ console.error(chalk.red('❌ --account is required for inbox'));
10
+ process.exit(1);
11
+ }
12
+
13
+ const acctConfig = getAccount(config, opts.account);
14
+ const folder = opts.folder || 'INBOX';
15
+ const limit = parseInt(opts.limit, 10) || 20;
16
+ const unreadOnly = !!opts.unread;
17
+ const isJson = opts.output === 'json';
18
+
19
+ await withImap(acctConfig, config, async (client) => {
20
+ const mailbox = await client.getMailboxLock(folder);
21
+ try {
22
+ const total = client.mailbox.exists;
23
+ let unreadCount = 0;
24
+
25
+ // Count unread
26
+ try {
27
+ const status = await client.status(folder, { unseen: true });
28
+ unreadCount = status.unseen || 0;
29
+ } catch (e) {
30
+ unreadCount = 0;
31
+ }
32
+
33
+ if (!isJson) {
34
+ const unreadLabel = unreadCount > 0 ? chalk.yellow.bold(`${unreadCount} unread`) : chalk.green.bold(`0 unread`);
35
+ console.log(chalk.cyan.bold(`\n \uD83D\uDCEC Inbox`) + chalk.white.bold(` \u2014 ${opts.account}`) + chalk.gray(` (`) + unreadLabel + chalk.gray(`, ${total} total)`));
36
+ console.log();
37
+ }
38
+
39
+ if (total === 0) {
40
+ if (isJson) {
41
+ console.log(JSON.stringify([]));
42
+ } else {
43
+ console.log(' No messages.');
44
+ }
45
+ return;
46
+ }
47
+
48
+ const query = unreadOnly ? { seen: false } : { all: true };
49
+ const range = `${Math.max(1, total - limit + 1)}:${total}`;
50
+
51
+ const messages = [];
52
+ for await (const msg of client.fetch(range, {
53
+ envelope: true,
54
+ flags: true,
55
+ bodyStructure: true
56
+ })) {
57
+ messages.push(msg);
58
+ }
59
+
60
+ messages.sort((a, b) => b.seq - a.seq);
61
+
62
+ if (unreadOnly) {
63
+ messages.splice(0, messages.length, ...messages.filter(m => !m.flags.has('\\Seen')));
64
+ }
65
+
66
+ const display = messages.slice(0, limit);
67
+
68
+ if (isJson) {
69
+ const jsonMessages = display.map(msg => {
70
+ const env = msg.envelope;
71
+ const from = env.from && env.from[0]
72
+ ? { name: env.from[0].name || null, address: env.from[0].address || null }
73
+ : null;
74
+ const hasAttach = msg.bodyStructure && msg.bodyStructure.type === 'multipart';
75
+ return {
76
+ seq: msg.seq,
77
+ subject: env.subject || null,
78
+ from,
79
+ date: env.date ? new Date(env.date).toISOString() : null,
80
+ flags: Array.from(msg.flags || []),
81
+ hasAttachment: hasAttach
82
+ };
83
+ });
84
+ console.log(JSON.stringify(jsonMessages, null, 2));
85
+ return;
86
+ }
87
+
88
+ const table = makeTable(['#', 'FLAGS', 'SUBJECT', 'FROM', 'DATE'], [6, 8, 35, 25, 18]);
89
+
90
+ for (const msg of display) {
91
+ const env = msg.envelope;
92
+ const from = env.from && env.from[0]
93
+ ? (env.from[0].name || env.from[0].address || '')
94
+ : '';
95
+ const hasAttach = msg.bodyStructure && msg.bodyStructure.type === 'multipart';
96
+ const flags = messageFlags(msg.flags, hasAttach);
97
+ const isUnread = msg.flags && !msg.flags.has('\\Seen');
98
+
99
+ const numColor = isUnread ? chalk.cyan.bold : chalk.white;
100
+ const subjColor = isUnread ? chalk.white.bold : chalk.white;
101
+ const fromColor = isUnread ? chalk.green.bold : chalk.green;
102
+ const dateColor = isUnread ? chalk.yellow.bold : chalk.yellow;
103
+
104
+ table.push([
105
+ numColor(String(msg.seq)),
106
+ flags,
107
+ subjColor(truncate(env.subject || '(no subject)', 33)),
108
+ fromColor(truncate(from, 23)),
109
+ dateColor(formatDate(env.date))
110
+ ]);
111
+ }
112
+
113
+ console.log(table.toString());
114
+ } finally {
115
+ mailbox.release();
116
+ }
117
+ });
118
+ }
119
+
120
+ const help = `
121
+ stellamail inbox — List inbox messages
122
+
123
+ USAGE:
124
+ stellamail inbox --account <name> [options]
125
+
126
+ OPTIONS:
127
+ --account <name> Account name (required)
128
+ --limit <n> Number of messages (default 20)
129
+ --folder <name> Folder name (default INBOX)
130
+ --unread Only show unread messages
131
+ --output json Output as JSON
132
+ --config <path> Path to config file
133
+ `;
134
+
135
+ module.exports = { run, help };