nothumanallowed 13.5.116 → 13.5.118

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nothumanallowed",
3
- "version": "13.5.116",
3
+ "version": "13.5.118",
4
4
  "description": "NotHumanAllowed — 38 AI agents, 80 tools, Studio (visual agentic workflows). Email, calendar, browser automation, screen capture, canvas, cron/heartbeat, Alexandria E2E messaging, GitHub, Notion, Slack, voice chat, free AI (Liara), 28 languages. Zero-dependency CLI.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/constants.mjs CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url';
5
5
  const __filename = fileURLToPath(import.meta.url);
6
6
  const __dirname = path.dirname(__filename);
7
7
 
8
- export const VERSION = '13.5.116';
8
+ export const VERSION = '13.5.118';
9
9
  export const BASE_URL = 'https://nothumanallowed.com/cli';
10
10
  export const API_BASE = 'https://nothumanallowed.com/api/v1';
11
11
 
@@ -58,16 +58,17 @@ function notifyIdle(accountId, folder) {
58
58
  }
59
59
 
60
60
  function createImapClient(label, creds, accountId) {
61
- const isSecure = creds.imap_port === 993;
61
+ const port = parseInt(creds.imap_port, 10) || 993;
62
+ const isSecure = port === 993 || port === 465;
62
63
  const client = new ImapFlow({
63
64
  host: creds.imap_host,
64
- port: creds.imap_port,
65
+ port,
65
66
  secure: isSecure,
66
67
  auth: { user: creds.username, pass: creds.password },
67
68
  logger: false,
68
69
  clientInfo: { name: 'NHA-Mail', version: '1.0.0' },
69
70
  emitLogs: false,
70
- ...(!isSecure && { tls: { rejectUnauthorized: false } }),
71
+ tls: { rejectUnauthorized: false }, // always set — safe for self-signed certs too
71
72
  });
72
73
  client.on('error', (err) => {
73
74
  console.error(`[email:imap] ${label} error:`, err.message);
@@ -147,36 +148,51 @@ function stripQuotedReplies(text) {
147
148
  return text.split('\n').filter(l => !l.startsWith('>')).join('\n').trim();
148
149
  }
149
150
 
150
- export async function syncFolder(accountId, folderPath, fullResync) {
151
+ export async function syncFolder(accountId, folderPath, fullResync, limitMessages = 0) {
151
152
  const client = await getImapClient(accountId);
152
153
  const db = getDb();
153
154
  const folderMeta = getFolder(accountId, folderPath);
155
+
156
+ // Open mailbox to get uidValidity BEFORE getMailboxLock
154
157
  const lock = await client.getMailboxLock(folderPath);
155
158
 
156
159
  try {
157
160
  const mailbox = client.mailbox;
158
161
  const currentUidValidity = Number(mailbox?.uidValidity ?? 0);
162
+ const totalMessages = Number(mailbox?.exists ?? 0);
159
163
  const storedUidValidity = folderMeta?.uid_validity ?? null;
160
- const lastUid = (fullResync || storedUidValidity !== currentUidValidity) ? 0 : (folderMeta?.last_uid ?? 0);
164
+ const isFirstSync = storedUidValidity === null || fullResync;
165
+ const lastUid = isFirstSync ? 0 : (folderMeta?.last_uid ?? 0);
161
166
  const needsResync = fullResync || (storedUidValidity !== null && storedUidValidity !== currentUidValidity);
162
167
 
163
168
  if (needsResync && folderMeta) {
164
- // Clear existing messages for this folder on UID validity change
165
169
  db.prepare('DELETE FROM email_message_labels WHERE message_id IN (SELECT id FROM email_messages WHERE account_id = ? AND imap_folder_path = ?)').run(accountId, folderPath);
166
170
  db.prepare('DELETE FROM email_messages WHERE account_id = ? AND imap_folder_path = ?').run(accountId, folderPath);
167
171
  }
168
172
 
169
173
  const inboxLabel = getSystemLabel(accountId, 'inbox');
170
174
  const sentLabel = getSystemLabel(accountId, 'sent');
171
- const blockedLabel = getSystemLabel(accountId, 'spam');
172
175
 
173
176
  let newLastUid = lastUid;
174
177
  let synced = 0;
175
178
 
176
- const range = lastUid > 0 ? `${lastUid + 1}:*` : '1:*';
179
+ // On first sync: fetch only the most recent N messages by sequence number
180
+ // On incremental sync: fetch new UIDs since last known
181
+ let range;
182
+ if (isFirstSync && limitMessages > 0 && totalMessages > limitMessages) {
183
+ const seqStart = totalMessages - limitMessages + 1;
184
+ range = `${seqStart}:*`;
185
+ } else if (lastUid > 0) {
186
+ range = `${lastUid + 1}:*`;
187
+ } else {
188
+ range = '1:*';
189
+ }
177
190
 
191
+ // Phase 1: fetch headers only (fast — no body download)
192
+ const headerMap = new Map(); // uid → parsed header data
178
193
  for await (const msg of client.fetch(range, {
179
194
  uid: true, flags: true, envelope: true, internalDate: true, size: true,
195
+ bodyStructure: true,
180
196
  }, { uid: true })) {
181
197
  if (msg.uid <= lastUid) continue;
182
198
  newLastUid = Math.max(newLastUid, msg.uid);
@@ -185,36 +201,48 @@ export async function syncFolder(accountId, folderPath, fullResync) {
185
201
  const fromAddr = env.from?.[0]?.address ?? null;
186
202
  const fromName = env.from?.[0]?.name ?? null;
187
203
 
188
- // Skip blocked senders
189
- if (fromAddr && isSenderBlocked(accountId, fromAddr)) {
190
- synced++;
191
- continue;
192
- }
193
-
194
- // Check if already synced
204
+ if (fromAddr && isSenderBlocked(accountId, fromAddr)) { synced++; continue; }
195
205
  if (messageExists(accountId, folderPath, msg.uid)) { synced++; continue; }
196
206
 
197
- // Fetch full body for this message
207
+ headerMap.set(msg.uid, {
208
+ env, fromAddr, fromName,
209
+ internalDate: (msg.internalDate || new Date()).toISOString(),
210
+ size: msg.size || 0,
211
+ flags: msg.flags,
212
+ bodyStructure: msg.bodyStructure,
213
+ });
214
+ }
215
+
216
+ // Phase 2: for each new message, fetch body (individually, with error isolation)
217
+ const uidsToFetch = [...headerMap.keys()];
218
+ for (const uid of uidsToFetch) {
219
+ const hdr = headerMap.get(uid);
220
+ const env = hdr.env;
221
+ const fromAddr = hdr.fromAddr;
222
+ const fromName = hdr.fromName;
223
+
198
224
  let bodyText = null, bodyHtml = null, bodyPreview = '', attachments = [];
199
225
  let inReplyTo = null, references = [], msgId = null;
200
226
 
201
227
  try {
202
- const dl = await client.download(String(msg.uid), undefined, { uid: true });
228
+ const dl = await client.download(String(uid), undefined, { uid: true });
203
229
  if (dl) {
204
230
  const chunks = [];
205
231
  let totalSize = 0;
206
232
  for await (const chunk of dl.content) {
207
233
  totalSize += chunk.length;
208
- if (totalSize > 25 * 1024 * 1024) break; // 25MB cap
234
+ if (totalSize > 10 * 1024 * 1024) break; // 10MB cap per message
209
235
  chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
210
236
  }
211
237
  const raw = Buffer.concat(chunks);
212
- const parsed = await simpleParser(raw);
238
+ const parsed = await simpleParser(raw, { skipHtmlToText: false });
213
239
  msgId = parsed.messageId || null;
214
- inReplyTo = typeof parsed.inReplyTo === 'string' ? parsed.inReplyTo : (Array.isArray(parsed.inReplyTo) ? parsed.inReplyTo[0] : null);
215
- references = Array.isArray(parsed.references) ? parsed.references.slice(0, 20) : (parsed.references ? [parsed.references] : []);
216
- bodyText = parsed.text?.replace(/\x00/g, '') || null;
217
- bodyHtml = parsed.html?.replace(/\x00/g, '') || null;
240
+ inReplyTo = typeof parsed.inReplyTo === 'string' ? parsed.inReplyTo
241
+ : (Array.isArray(parsed.inReplyTo) ? parsed.inReplyTo[0] : null);
242
+ references = Array.isArray(parsed.references) ? parsed.references.slice(0, 20)
243
+ : (parsed.references ? [parsed.references] : []);
244
+ bodyText = (typeof parsed.text === 'string' ? parsed.text : null)?.replace(/\x00/g, '') || null;
245
+ bodyHtml = (typeof parsed.html === 'string' ? parsed.html : null)?.replace(/\x00/g, '') || null;
218
246
  bodyPreview = (bodyText || '').replace(/\s+/g, ' ').trim().slice(0, 255);
219
247
 
220
248
  if (parsed.attachments) {
@@ -225,30 +253,31 @@ export async function syncFolder(accountId, folderPath, fullResync) {
225
253
  size_bytes: att.size || 0,
226
254
  part_id: att.partId || '',
227
255
  content_id: att.contentId || null,
228
- content: att.size < 5 * 1024 * 1024 ? att.content : null,
256
+ content: (att.size || 0) < 5 * 1024 * 1024 ? att.content : null,
229
257
  });
230
258
  }
231
259
  }
232
260
  }
233
261
  } catch (err) {
234
- console.warn(`[email:imap] Failed to fetch body uid=${msg.uid}:`, err.message);
262
+ console.warn(`[email:imap] Failed to fetch body uid=${uid}:`, err.message);
263
+ // Continue with headers-only for this message
235
264
  }
236
265
 
237
266
  const toAddresses = (env.to || []).map(a => ({ address: a.address, name: a.name }));
238
267
  const ccAddresses = (env.cc || []).map(a => ({ address: a.address, name: a.name }));
239
268
  const bccAddresses = (env.bcc || []).map(a => ({ address: a.address, name: a.name }));
240
269
  const subject = env.subject || '';
241
- const internalDate = (msg.internalDate || new Date()).toISOString();
242
- const tid = threadId(msgId, inReplyTo, references, fromAddr, internalDate, subject);
243
- const hash = contentHash(msgId || '', fromAddr || '', internalDate);
270
+ const tid = threadId(msgId, inReplyTo, references, fromAddr, hdr.internalDate, subject);
271
+ const hash = contentHash(msgId || '', fromAddr || '', hdr.internalDate);
244
272
 
245
- const folderRec = upsertFolder(accountId, folderPath, folderPath, folderMeta?.folder_type || 'custom', currentUidValidity, newLastUid);
273
+ const folderRec = upsertFolder(accountId, folderPath, folderPath,
274
+ folderMeta?.folder_type || 'custom', currentUidValidity, newLastUid);
246
275
 
247
276
  const msgDbId = insertMessage({
248
277
  account_id: accountId,
249
278
  folder_id: folderRec,
250
279
  imap_folder_path: folderPath,
251
- uid: msg.uid,
280
+ uid,
252
281
  message_id: msgId,
253
282
  in_reply_to: inReplyTo,
254
283
  references_list: references,
@@ -263,42 +292,41 @@ export async function syncFolder(accountId, folderPath, fullResync) {
263
292
  body_html: bodyHtml,
264
293
  body_preview: bodyPreview,
265
294
  body_reply_only: stripQuotedReplies(bodyText),
266
- size_bytes: msg.size || 0,
295
+ size_bytes: hdr.size,
267
296
  has_attachments: attachments.length > 0,
268
297
  content_hash: hash,
269
- internal_date: internalDate,
298
+ internal_date: hdr.internalDate,
270
299
  source: 'imap',
271
300
  });
272
301
 
273
302
  if (attachments.length > 0) insertAttachments(msgDbId, attachments);
274
303
 
275
- // Apply archiving rules (first match wins, removes from inbox)
276
304
  const archived = applyArchivingRules(accountId, msgDbId, fromAddr, subject);
277
305
  if (!archived) {
278
- // Assign label based on folder type
279
- const isSent = folderPath.toLowerCase().includes('sent') || folderPath.toLowerCase().includes('inviati');
280
- const targetLabel = isSent ? sentLabel : inboxLabel;
306
+ const isSentFolder = folderPath.toLowerCase().includes('sent') || folderPath.toLowerCase().includes('inviati');
307
+ const targetLabel = isSentFolder ? sentLabel : inboxLabel;
281
308
  if (targetLabel) addMessageToLabel(msgDbId, targetLabel.id);
282
309
  }
283
310
 
284
311
  synced++;
285
312
  }
286
313
 
287
- // Update folder sync state
288
- upsertFolder(accountId, folderPath,
289
- folderPath, folderMeta?.folder_type || 'custom',
290
- currentUidValidity, newLastUid);
314
+ upsertFolder(accountId, folderPath, folderPath,
315
+ folderMeta?.folder_type || 'custom', currentUidValidity, newLastUid);
291
316
 
292
- return { synced, lastUid: newLastUid };
317
+ updateLabelCounts(accountId);
318
+ return { synced, lastUid: newLastUid, total: totalMessages };
293
319
  } finally {
294
320
  lock.release();
295
321
  }
296
322
  }
297
323
 
298
- export async function syncAccount(accountId) {
324
+ // First sync: cap to last 200 messages per folder to avoid blocking for minutes
325
+ const FIRST_SYNC_LIMIT = 200;
326
+
327
+ export async function syncAccount(accountId, opts = {}) {
299
328
  setSyncStatus(accountId, 'syncing', null);
300
329
  try {
301
- // List folders from IMAP and sync INBOX + Sent
302
330
  const folders = await listImapFolders(accountId);
303
331
  const priority = ['inbox', 'sent'];
304
332
  const toSync = [
@@ -309,8 +337,10 @@ export async function syncAccount(accountId) {
309
337
  let totalSynced = 0;
310
338
  for (const f of toSync) {
311
339
  try {
312
- const result = await syncFolder(accountId, f.path, false);
340
+ const limit = opts.full ? 0 : FIRST_SYNC_LIMIT;
341
+ const result = await syncFolder(accountId, f.path, false, limit);
313
342
  totalSynced += result.synced;
343
+ console.log(`[email:sync] ${f.path}: ${result.synced} new messages (total on server: ${result.total})`);
314
344
  } catch (err) {
315
345
  console.warn(`[email:imap] Sync folder ${f.path} failed:`, err.message);
316
346
  }
@@ -19,16 +19,17 @@ function threadId(messageId) {
19
19
  }
20
20
 
21
21
  function getTransporter(creds) {
22
- const isSecure = creds.smtp_port === 465;
22
+ const port = parseInt(creds.smtp_port, 10) || 587;
23
+ const isSecure = port === 465;
23
24
  return createTransport({
24
25
  host: creds.smtp_host,
25
- port: creds.smtp_port,
26
+ port,
26
27
  secure: isSecure,
27
28
  auth: { user: creds.username, pass: creds.password },
28
29
  connectionTimeout: 15000,
29
30
  greetingTimeout: 10000,
30
31
  socketTimeout: 20000,
31
- tls: { rejectUnauthorized: isSecure || creds.smtp_port === 587 },
32
+ tls: { rejectUnauthorized: false },
32
33
  });
33
34
  }
34
35
 
@@ -3143,12 +3143,12 @@ function renderImapAccountsSettings() {
3143
3143
  imapField('imapDisplayName','Display Name','e.g. Work Email') +
3144
3144
  imapField('imapEmail','Email Address','user@example.com') +
3145
3145
  imapField('imapFromName','From Name','e.g. John Smith') +
3146
- imapField('imapImapHost','IMAP Host','e.g. imap.gmail.com') +
3147
- imapField('imapImapPort','IMAP Port','993') +
3148
- imapField('imapSmtpHost','SMTP Host','e.g. smtp.gmail.com') +
3149
- imapField('imapSmtpPort','SMTP Port','587') +
3146
+ imapField('imapImapHost','IMAP Server','e.g. imap.gmail.com') +
3147
+ imapField('imapImapPort','IMAP Port','993 (TLS) or 143') +
3148
+ imapField('imapSmtpHost','SMTP Server','e.g. smtp.gmail.com') +
3149
+ imapField('imapSmtpPort','SMTP Port','587 (STARTTLS) or 465 (SSL)') +
3150
3150
  imapField('imapUsername','Username','e.g. user@example.com') +
3151
- imapField('imapPassword','Password','App password or IMAP password', true) +
3151
+ imapFieldPassword() +
3152
3152
  '<div style="display:flex;gap:8px;margin-top:12px">' +
3153
3153
  '<button onclick="saveImapAccount()" style="background:var(--green3);color:var(--bg);padding:7px 18px;border-radius:var(--r);font-weight:700;font-size:12px;cursor:pointer;border:none">Save</button>' +
3154
3154
  '<button onclick="document.getElementById(\\x27imapAccountForm\\x27).style.display=\\x27none\\x27" style="background:var(--bg);color:var(--dim);padding:7px 14px;border-radius:var(--r);font-size:12px;cursor:pointer;border:1px solid var(--border)">Cancel</button>' +
@@ -3158,13 +3158,31 @@ function renderImapAccountsSettings() {
3158
3158
  '</div>';
3159
3159
  }
3160
3160
 
3161
- function imapField(id, label, placeholder, isPassword) {
3161
+ function imapField(id, label, placeholder) {
3162
3162
  return '<div style="margin-bottom:8px">' +
3163
3163
  '<label style="display:block;font-size:10px;color:var(--dim);margin-bottom:3px">' + label + '</label>' +
3164
- '<input id="' + id + '" type="' + (isPassword ? 'password' : 'text') + '" placeholder="' + placeholder + '" style="width:100%;padding:7px 10px;font-size:12px;background:var(--bg);color:var(--fg);border:1px solid var(--border2);border-radius:6px;box-sizing:border-box">' +
3164
+ '<input id="' + id + '" type="text" placeholder="' + placeholder + '" style="width:100%;padding:7px 10px;font-size:12px;background:var(--bg);color:var(--fg);border:1px solid var(--border2);border-radius:6px;box-sizing:border-box">' +
3165
3165
  '</div>';
3166
3166
  }
3167
3167
 
3168
+ function imapFieldPassword() {
3169
+ return '<div style="margin-bottom:8px">' +
3170
+ '<label style="display:block;font-size:10px;color:var(--dim);margin-bottom:3px">Password <span id="imapPwdPlaceholderNote" style="color:var(--amber,#F59E0B)">(leave empty to keep existing)</span></label>' +
3171
+ '<div style="display:flex;gap:6px;align-items:center">' +
3172
+ '<input id="imapPassword" type="password" placeholder="App password or IMAP password" style="flex:1;padding:7px 10px;font-size:12px;background:var(--bg);color:var(--fg);border:1px solid var(--border2);border-radius:6px">' +
3173
+ '<button type="button" onclick="imapTogglePwd()" style="padding:6px 10px;font-size:11px;background:var(--bg);color:var(--dim);border:1px solid var(--border);border-radius:6px;cursor:pointer;white-space:nowrap" id="imapPwdToggle">Show</button>' +
3174
+ '</div>' +
3175
+ '</div>';
3176
+ }
3177
+
3178
+ function imapTogglePwd() {
3179
+ var inp = document.getElementById('imapPassword');
3180
+ var btn = document.getElementById('imapPwdToggle');
3181
+ if (!inp) return;
3182
+ if (inp.type === 'password') { inp.type = 'text'; if (btn) btn.textContent = 'Hide'; }
3183
+ else { inp.type = 'password'; if (btn) btn.textContent = 'Show'; }
3184
+ }
3185
+
3168
3186
  function loadImapAccounts() {
3169
3187
  apiGet('/api/imap/accounts').then(function(r) {
3170
3188
  var el = document.getElementById('imapAccountsList');
@@ -3212,6 +3230,14 @@ function showAddImapAccount() {
3212
3230
  }
3213
3231
  var status = document.getElementById('imapFormStatus');
3214
3232
  if (status) status.textContent = '';
3233
+ // New account: hide "leave empty" note, require password
3234
+ var note = document.getElementById('imapPwdPlaceholderNote');
3235
+ if (note) note.style.display = 'none';
3236
+ // Reset show/hide toggle
3237
+ var pwdInp = document.getElementById('imapPassword');
3238
+ var pwdBtn = document.getElementById('imapPwdToggle');
3239
+ if (pwdInp) { pwdInp.type = 'password'; pwdInp.placeholder = 'App password or IMAP password'; }
3240
+ if (pwdBtn) pwdBtn.textContent = 'Show';
3215
3241
  form.style.display = 'block';
3216
3242
  form.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
3217
3243
  }
@@ -3231,7 +3257,17 @@ function editImapAccount(id) {
3231
3257
  document.getElementById('imapSmtpPort').value = a.smtp_port || 587;
3232
3258
  document.getElementById('imapUsername').value = a.username || '';
3233
3259
  document.getElementById('imapPassword').value = '';
3234
- document.getElementById('imapAccountForm').style.display = 'block';
3260
+ // Show "leave empty to keep" note and reset toggle
3261
+ var note = document.getElementById('imapPwdPlaceholderNote');
3262
+ if (note) note.style.display = 'inline';
3263
+ var pwdInp = document.getElementById('imapPassword');
3264
+ var pwdBtn = document.getElementById('imapPwdToggle');
3265
+ if (pwdInp) { pwdInp.type = 'password'; pwdInp.placeholder = 'Leave empty to keep existing'; }
3266
+ if (pwdBtn) pwdBtn.textContent = 'Show';
3267
+ var form2 = document.getElementById('imapAccountForm');
3268
+ var status2 = document.getElementById('imapFormStatus');
3269
+ if (status2) status2.textContent = '';
3270
+ if (form2) { form2.style.display = 'block'; form2.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }
3235
3271
  });
3236
3272
  }
3237
3273