nothumanallowed 13.5.117 → 13.5.119
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 +1 -1
- package/src/constants.mjs +1 -1
- package/src/services/email-db.mjs +3 -2
- package/src/services/email-imap.mjs +71 -41
- package/src/services/web-ui.mjs +66 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nothumanallowed",
|
|
3
|
-
"version": "13.5.
|
|
3
|
+
"version": "13.5.119",
|
|
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.
|
|
8
|
+
export const VERSION = '13.5.119';
|
|
9
9
|
export const BASE_URL = 'https://nothumanallowed.com/cli';
|
|
10
10
|
export const API_BASE = 'https://nothumanallowed.com/api/v1';
|
|
11
11
|
|
|
@@ -448,8 +448,9 @@ export function insertMessage(data) {
|
|
|
448
448
|
data.has_attachments ? 1 : 0, data.content_hash || null,
|
|
449
449
|
data.internal_date, data.source || 'imap'
|
|
450
450
|
);
|
|
451
|
-
// Init read state
|
|
452
|
-
|
|
451
|
+
// Init read state from IMAP \Seen flag
|
|
452
|
+
const isRead = data.imap_seen ? 1 : 0;
|
|
453
|
+
db.prepare('INSERT OR IGNORE INTO email_message_state (message_id, is_read, is_starred) VALUES (?, ?, 0)').run(id, isRead);
|
|
453
454
|
return id;
|
|
454
455
|
}
|
|
455
456
|
|
|
@@ -148,36 +148,51 @@ function stripQuotedReplies(text) {
|
|
|
148
148
|
return text.split('\n').filter(l => !l.startsWith('>')).join('\n').trim();
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
export async function syncFolder(accountId, folderPath, fullResync) {
|
|
151
|
+
export async function syncFolder(accountId, folderPath, fullResync, limitMessages = 0) {
|
|
152
152
|
const client = await getImapClient(accountId);
|
|
153
153
|
const db = getDb();
|
|
154
154
|
const folderMeta = getFolder(accountId, folderPath);
|
|
155
|
+
|
|
156
|
+
// Open mailbox to get uidValidity BEFORE getMailboxLock
|
|
155
157
|
const lock = await client.getMailboxLock(folderPath);
|
|
156
158
|
|
|
157
159
|
try {
|
|
158
160
|
const mailbox = client.mailbox;
|
|
159
161
|
const currentUidValidity = Number(mailbox?.uidValidity ?? 0);
|
|
162
|
+
const totalMessages = Number(mailbox?.exists ?? 0);
|
|
160
163
|
const storedUidValidity = folderMeta?.uid_validity ?? null;
|
|
161
|
-
const
|
|
164
|
+
const isFirstSync = storedUidValidity === null || fullResync;
|
|
165
|
+
const lastUid = isFirstSync ? 0 : (folderMeta?.last_uid ?? 0);
|
|
162
166
|
const needsResync = fullResync || (storedUidValidity !== null && storedUidValidity !== currentUidValidity);
|
|
163
167
|
|
|
164
168
|
if (needsResync && folderMeta) {
|
|
165
|
-
// Clear existing messages for this folder on UID validity change
|
|
166
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);
|
|
167
170
|
db.prepare('DELETE FROM email_messages WHERE account_id = ? AND imap_folder_path = ?').run(accountId, folderPath);
|
|
168
171
|
}
|
|
169
172
|
|
|
170
173
|
const inboxLabel = getSystemLabel(accountId, 'inbox');
|
|
171
174
|
const sentLabel = getSystemLabel(accountId, 'sent');
|
|
172
|
-
const blockedLabel = getSystemLabel(accountId, 'spam');
|
|
173
175
|
|
|
174
176
|
let newLastUid = lastUid;
|
|
175
177
|
let synced = 0;
|
|
176
178
|
|
|
177
|
-
|
|
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
|
+
}
|
|
178
190
|
|
|
191
|
+
// Phase 1: fetch headers only (fast — no body download)
|
|
192
|
+
const headerMap = new Map(); // uid → parsed header data
|
|
179
193
|
for await (const msg of client.fetch(range, {
|
|
180
194
|
uid: true, flags: true, envelope: true, internalDate: true, size: true,
|
|
195
|
+
bodyStructure: true,
|
|
181
196
|
}, { uid: true })) {
|
|
182
197
|
if (msg.uid <= lastUid) continue;
|
|
183
198
|
newLastUid = Math.max(newLastUid, msg.uid);
|
|
@@ -186,36 +201,48 @@ export async function syncFolder(accountId, folderPath, fullResync) {
|
|
|
186
201
|
const fromAddr = env.from?.[0]?.address ?? null;
|
|
187
202
|
const fromName = env.from?.[0]?.name ?? null;
|
|
188
203
|
|
|
189
|
-
|
|
190
|
-
if (fromAddr && isSenderBlocked(accountId, fromAddr)) {
|
|
191
|
-
synced++;
|
|
192
|
-
continue;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Check if already synced
|
|
204
|
+
if (fromAddr && isSenderBlocked(accountId, fromAddr)) { synced++; continue; }
|
|
196
205
|
if (messageExists(accountId, folderPath, msg.uid)) { synced++; continue; }
|
|
197
206
|
|
|
198
|
-
|
|
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
|
+
|
|
199
224
|
let bodyText = null, bodyHtml = null, bodyPreview = '', attachments = [];
|
|
200
225
|
let inReplyTo = null, references = [], msgId = null;
|
|
201
226
|
|
|
202
227
|
try {
|
|
203
|
-
const dl = await client.download(String(
|
|
228
|
+
const dl = await client.download(String(uid), undefined, { uid: true });
|
|
204
229
|
if (dl) {
|
|
205
230
|
const chunks = [];
|
|
206
231
|
let totalSize = 0;
|
|
207
232
|
for await (const chunk of dl.content) {
|
|
208
233
|
totalSize += chunk.length;
|
|
209
|
-
if (totalSize >
|
|
234
|
+
if (totalSize > 10 * 1024 * 1024) break; // 10MB cap per message
|
|
210
235
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
211
236
|
}
|
|
212
237
|
const raw = Buffer.concat(chunks);
|
|
213
|
-
const parsed = await simpleParser(raw);
|
|
238
|
+
const parsed = await simpleParser(raw, { skipHtmlToText: false });
|
|
214
239
|
msgId = parsed.messageId || null;
|
|
215
|
-
inReplyTo = typeof parsed.inReplyTo === 'string' ? parsed.inReplyTo
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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;
|
|
219
246
|
bodyPreview = (bodyText || '').replace(/\s+/g, ' ').trim().slice(0, 255);
|
|
220
247
|
|
|
221
248
|
if (parsed.attachments) {
|
|
@@ -226,30 +253,31 @@ export async function syncFolder(accountId, folderPath, fullResync) {
|
|
|
226
253
|
size_bytes: att.size || 0,
|
|
227
254
|
part_id: att.partId || '',
|
|
228
255
|
content_id: att.contentId || null,
|
|
229
|
-
content: att.size < 5 * 1024 * 1024 ? att.content : null,
|
|
256
|
+
content: (att.size || 0) < 5 * 1024 * 1024 ? att.content : null,
|
|
230
257
|
});
|
|
231
258
|
}
|
|
232
259
|
}
|
|
233
260
|
}
|
|
234
261
|
} catch (err) {
|
|
235
|
-
console.warn(`[email:imap] Failed to fetch body uid=${
|
|
262
|
+
console.warn(`[email:imap] Failed to fetch body uid=${uid}:`, err.message);
|
|
263
|
+
// Continue with headers-only for this message
|
|
236
264
|
}
|
|
237
265
|
|
|
238
266
|
const toAddresses = (env.to || []).map(a => ({ address: a.address, name: a.name }));
|
|
239
267
|
const ccAddresses = (env.cc || []).map(a => ({ address: a.address, name: a.name }));
|
|
240
268
|
const bccAddresses = (env.bcc || []).map(a => ({ address: a.address, name: a.name }));
|
|
241
269
|
const subject = env.subject || '';
|
|
242
|
-
const
|
|
243
|
-
const
|
|
244
|
-
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);
|
|
245
272
|
|
|
246
|
-
const folderRec = upsertFolder(accountId, folderPath, folderPath,
|
|
273
|
+
const folderRec = upsertFolder(accountId, folderPath, folderPath,
|
|
274
|
+
folderMeta?.folder_type || 'custom', currentUidValidity, newLastUid);
|
|
247
275
|
|
|
248
276
|
const msgDbId = insertMessage({
|
|
249
277
|
account_id: accountId,
|
|
250
278
|
folder_id: folderRec,
|
|
251
279
|
imap_folder_path: folderPath,
|
|
252
|
-
uid
|
|
280
|
+
uid,
|
|
253
281
|
message_id: msgId,
|
|
254
282
|
in_reply_to: inReplyTo,
|
|
255
283
|
references_list: references,
|
|
@@ -264,42 +292,42 @@ export async function syncFolder(accountId, folderPath, fullResync) {
|
|
|
264
292
|
body_html: bodyHtml,
|
|
265
293
|
body_preview: bodyPreview,
|
|
266
294
|
body_reply_only: stripQuotedReplies(bodyText),
|
|
267
|
-
size_bytes:
|
|
295
|
+
size_bytes: hdr.size,
|
|
268
296
|
has_attachments: attachments.length > 0,
|
|
269
297
|
content_hash: hash,
|
|
270
|
-
internal_date: internalDate,
|
|
298
|
+
internal_date: hdr.internalDate,
|
|
299
|
+
imap_seen: hdr.flags?.has('\\Seen') ?? false,
|
|
271
300
|
source: 'imap',
|
|
272
301
|
});
|
|
273
302
|
|
|
274
303
|
if (attachments.length > 0) insertAttachments(msgDbId, attachments);
|
|
275
304
|
|
|
276
|
-
// Apply archiving rules (first match wins, removes from inbox)
|
|
277
305
|
const archived = applyArchivingRules(accountId, msgDbId, fromAddr, subject);
|
|
278
306
|
if (!archived) {
|
|
279
|
-
|
|
280
|
-
const
|
|
281
|
-
const targetLabel = isSent ? sentLabel : inboxLabel;
|
|
307
|
+
const isSentFolder = folderPath.toLowerCase().includes('sent') || folderPath.toLowerCase().includes('inviati');
|
|
308
|
+
const targetLabel = isSentFolder ? sentLabel : inboxLabel;
|
|
282
309
|
if (targetLabel) addMessageToLabel(msgDbId, targetLabel.id);
|
|
283
310
|
}
|
|
284
311
|
|
|
285
312
|
synced++;
|
|
286
313
|
}
|
|
287
314
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
folderPath, folderMeta?.folder_type || 'custom',
|
|
291
|
-
currentUidValidity, newLastUid);
|
|
315
|
+
upsertFolder(accountId, folderPath, folderPath,
|
|
316
|
+
folderMeta?.folder_type || 'custom', currentUidValidity, newLastUid);
|
|
292
317
|
|
|
293
|
-
|
|
318
|
+
updateLabelCounts(accountId);
|
|
319
|
+
return { synced, lastUid: newLastUid, total: totalMessages };
|
|
294
320
|
} finally {
|
|
295
321
|
lock.release();
|
|
296
322
|
}
|
|
297
323
|
}
|
|
298
324
|
|
|
299
|
-
|
|
325
|
+
// First sync: cap to last 200 messages per folder to avoid blocking for minutes
|
|
326
|
+
const FIRST_SYNC_LIMIT = 200;
|
|
327
|
+
|
|
328
|
+
export async function syncAccount(accountId, opts = {}) {
|
|
300
329
|
setSyncStatus(accountId, 'syncing', null);
|
|
301
330
|
try {
|
|
302
|
-
// List folders from IMAP and sync INBOX + Sent
|
|
303
331
|
const folders = await listImapFolders(accountId);
|
|
304
332
|
const priority = ['inbox', 'sent'];
|
|
305
333
|
const toSync = [
|
|
@@ -310,8 +338,10 @@ export async function syncAccount(accountId) {
|
|
|
310
338
|
let totalSynced = 0;
|
|
311
339
|
for (const f of toSync) {
|
|
312
340
|
try {
|
|
313
|
-
const
|
|
341
|
+
const limit = opts.full ? 0 : FIRST_SYNC_LIMIT;
|
|
342
|
+
const result = await syncFolder(accountId, f.path, false, limit);
|
|
314
343
|
totalSynced += result.synced;
|
|
344
|
+
console.log(`[email:sync] ${f.path}: ${result.synced} new messages (total on server: ${result.total})`);
|
|
315
345
|
} catch (err) {
|
|
316
346
|
console.warn(`[email:imap] Sync folder ${f.path} failed:`, err.message);
|
|
317
347
|
}
|
package/src/services/web-ui.mjs
CHANGED
|
@@ -1463,18 +1463,83 @@ function emailRenderReadingPane(pane, m) {
|
|
|
1463
1463
|
pane.innerHTML = '<div style="display:flex;flex-direction:column;height:100%">' + h + '</div>';
|
|
1464
1464
|
}
|
|
1465
1465
|
|
|
1466
|
+
var EMAIL_TEMPLATES = [
|
|
1467
|
+
{
|
|
1468
|
+
id: 'promo_product',
|
|
1469
|
+
name: 'Promozione prodotto',
|
|
1470
|
+
subject: 'Scopri la nostra offerta su [PRODOTTO]',
|
|
1471
|
+
html: '<table width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;margin:0 auto;font-family:Arial,sans-serif"><tr><td style="background:#1a1a2e;padding:32px 40px;text-align:center"><h1 style="color:#00ff9d;margin:0;font-size:26px;letter-spacing:-0.5px">[AZIENDA]</h1></td></tr><tr><td style="padding:40px;background:#ffffff"><h2 style="color:#1a1a2e;font-size:22px;margin:0 0 16px">[TITOLO OFFERTA]</h2><p style="color:#444;font-size:15px;line-height:1.7;margin:0 0 24px">[DESCRIZIONE PRODOTTO/SERVIZIO]</p><p style="color:#444;font-size:15px;line-height:1.7;margin:0 0 32px">[DETTAGLIO BENEFICI O SPECIFICHE]</p><table cellpadding="0" cellspacing="0"><tr><td style="background:#00ff9d;border-radius:6px;padding:14px 32px"><a href="[LINK_CTA]" style="color:#1a1a2e;font-weight:700;font-size:15px;text-decoration:none">[TESTO CTA]</a></td></tr></table></td></tr><tr><td style="padding:24px 40px;background:#f5f5f5;text-align:center"><p style="color:#888;font-size:12px;margin:0">[AZIENDA] • [INDIRIZZO] • <a href="mailto:[EMAIL]" style="color:#888">[EMAIL]</a></p><p style="color:#bbb;font-size:11px;margin:8px 0 0"><a href="[UNSUBSCRIBE_LINK]" style="color:#bbb">Disiscriviti</a></p></td></tr></table>',
|
|
1472
|
+
},
|
|
1473
|
+
{
|
|
1474
|
+
id: 'newsletter',
|
|
1475
|
+
name: 'Newsletter mensile',
|
|
1476
|
+
subject: '[AZIENDA] Newsletter — [MESE] [ANNO]',
|
|
1477
|
+
html: '<table width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;margin:0 auto;font-family:Arial,sans-serif"><tr><td style="background:#0f0f1a;padding:28px 40px;border-bottom:3px solid #00ff9d"><h1 style="color:#ffffff;margin:0;font-size:22px">[AZIENDA]</h1><p style="color:#00ff9d;margin:6px 0 0;font-size:13px">Newsletter [MESE] [ANNO]</p></td></tr><tr><td style="padding:36px 40px;background:#ffffff"><h2 style="color:#1a1a2e;font-size:20px;margin:0 0 12px">[TITOLO PRINCIPALE]</h2><p style="color:#555;font-size:14px;line-height:1.8;margin:0 0 28px">[TESTO PRINCIPALE — racconta la novità principale del mese]</p><hr style="border:none;border-top:1px solid #eee;margin:0 0 28px"><h3 style="color:#1a1a2e;font-size:16px;margin:0 0 10px">[TITOLO SEZIONE 2]</h3><p style="color:#555;font-size:14px;line-height:1.8;margin:0 0 28px">[TESTO SEZIONE 2]</p><hr style="border:none;border-top:1px solid #eee;margin:0 0 28px"><h3 style="color:#1a1a2e;font-size:16px;margin:0 0 10px">[TITOLO SEZIONE 3]</h3><p style="color:#555;font-size:14px;line-height:1.8;margin:0 0 28px">[TESTO SEZIONE 3]</p><table cellpadding="0" cellspacing="0"><tr><td style="background:#1a1a2e;border-radius:6px;padding:12px 28px"><a href="[LINK]" style="color:#00ff9d;font-weight:700;font-size:14px;text-decoration:none">Leggi di più →</a></td></tr></table></td></tr><tr><td style="padding:20px 40px;background:#f9f9f9;text-align:center"><p style="color:#999;font-size:12px;margin:0">© [ANNO] [AZIENDA] • <a href="[UNSUBSCRIBE_LINK]" style="color:#999">Disiscriviti</a></p></td></tr></table>',
|
|
1478
|
+
},
|
|
1479
|
+
{
|
|
1480
|
+
id: 'follow_up',
|
|
1481
|
+
name: 'Follow-up commerciale',
|
|
1482
|
+
subject: 'Seguito alla nostra conversazione — [ARGOMENTO]',
|
|
1483
|
+
html: '<table width="100%" cellpadding="0" cellspacing="0" style="max-width:580px;margin:0 auto;font-family:Arial,sans-serif"><tr><td style="padding:40px"><p style="color:#333;font-size:15px;line-height:1.7;margin:0 0 16px">Gentile [NOME],</p><p style="color:#333;font-size:15px;line-height:1.7;margin:0 0 16px">la contatto in seguito a [CONTESTO: fiera / chiamata / incontro del GIORNO].</p><p style="color:#333;font-size:15px;line-height:1.7;margin:0 0 16px">[CORPO PRINCIPALE: riassumi la proposta, il valore offerto, eventuali prossimi passi]</p><p style="color:#333;font-size:15px;line-height:1.7;margin:0 0 32px">[CHIUSURA: proposta di call / richiesta feedback / disponibilità a incontro]</p><p style="color:#333;font-size:15px;line-height:1.7;margin:0 0 4px">Cordiali saluti,</p><p style="color:#1a1a2e;font-size:15px;font-weight:700;margin:0">[NOME MITTENTE]</p><p style="color:#888;font-size:13px;margin:4px 0 0">[RUOLO] • [AZIENDA] • [TELEFONO]</p></td></tr></table>',
|
|
1484
|
+
},
|
|
1485
|
+
{
|
|
1486
|
+
id: 'offerta',
|
|
1487
|
+
name: 'Invio offerta / preventivo',
|
|
1488
|
+
subject: 'Offerta [NUMERO] — [OGGETTO FORNITURA]',
|
|
1489
|
+
html: '<table width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;margin:0 auto;font-family:Arial,sans-serif"><tr><td style="background:#1a1a2e;padding:24px 40px"><h1 style="color:#00ff9d;margin:0;font-size:20px">[AZIENDA]</h1><p style="color:#aaa;margin:4px 0 0;font-size:12px">Offerta n. [NUMERO] del [DATA]</p></td></tr><tr><td style="padding:36px 40px;background:#ffffff"><p style="color:#333;font-size:15px;line-height:1.7;margin:0 0 16px">Gentile [NOME],</p><p style="color:#333;font-size:15px;line-height:1.7;margin:0 0 24px">in risposta alla Vostra richiesta, siamo lieti di sottoporre la seguente offerta:</p><table width="100%" cellpadding="10" cellspacing="0" style="border-collapse:collapse;margin-bottom:24px"><tr style="background:#f0f0f0"><th style="text-align:left;font-size:13px;color:#333;border-bottom:2px solid #ddd">Descrizione</th><th style="text-align:right;font-size:13px;color:#333;border-bottom:2px solid #ddd">Qtà</th><th style="text-align:right;font-size:13px;color:#333;border-bottom:2px solid #ddd">Prezzo unit.</th><th style="text-align:right;font-size:13px;color:#333;border-bottom:2px solid #ddd">Totale</th></tr><tr><td style="font-size:13px;color:#444;border-bottom:1px solid #eee">[ARTICOLO 1]</td><td style="text-align:right;font-size:13px;color:#444;border-bottom:1px solid #eee">[QT]</td><td style="text-align:right;font-size:13px;color:#444;border-bottom:1px solid #eee">€ [PREZZO]</td><td style="text-align:right;font-size:13px;color:#444;border-bottom:1px solid #eee">€ [TOT]</td></tr><tr><td style="font-size:13px;color:#444;border-bottom:1px solid #eee">[ARTICOLO 2]</td><td style="text-align:right;font-size:13px;color:#444;border-bottom:1px solid #eee">[QT]</td><td style="text-align:right;font-size:13px;color:#444;border-bottom:1px solid #eee">€ [PREZZO]</td><td style="text-align:right;font-size:13px;color:#444;border-bottom:1px solid #eee">€ [TOT]</td></tr><tr style="background:#f9f9f9"><td colspan="3" style="text-align:right;font-weight:700;font-size:14px;color:#1a1a2e;padding-top:12px">Totale IVA esclusa</td><td style="text-align:right;font-weight:700;font-size:14px;color:#1a1a2e;padding-top:12px">€ [TOTALE]</td></tr></table><p style="color:#555;font-size:13px;line-height:1.7;margin:0 0 8px"><strong>Condizioni di pagamento:</strong> [PAGAMENTO]</p><p style="color:#555;font-size:13px;line-height:1.7;margin:0 0 8px"><strong>Tempi di consegna:</strong> [CONSEGNA]</p><p style="color:#555;font-size:13px;line-height:1.7;margin:0 0 24px"><strong>Validità offerta:</strong> [VALIDITA]</p><p style="color:#333;font-size:14px;line-height:1.7;margin:0 0 4px">Resto a disposizione per qualsiasi chiarimento.</p><p style="color:#333;font-size:14px;line-height:1.7;margin:0 0 4px">Cordiali saluti,</p><p style="color:#1a1a2e;font-size:14px;font-weight:700;margin:0">[NOME MITTENTE]</p><p style="color:#888;font-size:12px;margin:4px 0 0">[AZIENDA] • [EMAIL] • [TELEFONO]</p></td></tr></table>',
|
|
1490
|
+
},
|
|
1491
|
+
{
|
|
1492
|
+
id: 'evento',
|
|
1493
|
+
name: 'Invito evento / webinar',
|
|
1494
|
+
subject: 'Sei invitato: [NOME EVENTO] — [DATA]',
|
|
1495
|
+
html: '<table width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;margin:0 auto;font-family:Arial,sans-serif"><tr><td style="background:linear-gradient(135deg,#0f0f1a,#1a2a1a);padding:48px 40px;text-align:center"><h1 style="color:#00ff9d;margin:0 0 8px;font-size:28px;letter-spacing:-0.5px">[NOME EVENTO]</h1><p style="color:#aaffcc;margin:0;font-size:16px">[DATA] • [ORA] • [LUOGO / Online]</p></td></tr><tr><td style="padding:40px;background:#ffffff;text-align:center"><p style="color:#444;font-size:15px;line-height:1.8;max-width:460px;margin:0 auto 28px">[DESCRIZIONE EVENTO: di cosa si tratta, a chi è rivolto, perché non perderselo]</p><p style="color:#555;font-size:14px;line-height:1.8;max-width:460px;margin:0 auto 32px">[AGENDA O PUNTI CHIAVE]</p><table cellpadding="0" cellspacing="0" style="margin:0 auto 32px"><tr><td style="background:#00ff9d;border-radius:8px;padding:14px 40px"><a href="[LINK_REGISTRAZIONE]" style="color:#0f0f1a;font-weight:700;font-size:15px;text-decoration:none">Registrati ora →</a></td></tr></table><p style="color:#999;font-size:12px;margin:0">Posti limitati • Registrazione gratuita</p></td></tr><tr><td style="padding:20px 40px;background:#f5f5f5;text-align:center"><p style="color:#bbb;font-size:11px;margin:0">[AZIENDA] • <a href="[UNSUBSCRIBE_LINK]" style="color:#bbb">Disiscriviti</a></p></td></tr></table>',
|
|
1496
|
+
},
|
|
1497
|
+
{
|
|
1498
|
+
id: 'ringraziamento',
|
|
1499
|
+
name: 'Ringraziamento cliente',
|
|
1500
|
+
subject: 'Grazie per la fiducia, [NOME]',
|
|
1501
|
+
html: '<table width="100%" cellpadding="0" cellspacing="0" style="max-width:580px;margin:0 auto;font-family:Arial,sans-serif"><tr><td style="background:#0f0f1a;padding:32px 40px;text-align:center"><p style="color:#00ff9d;font-size:36px;margin:0">🙏</p><h1 style="color:#ffffff;font-size:22px;margin:8px 0 0">Grazie, [NOME]!</h1></td></tr><tr><td style="padding:40px;background:#ffffff"><p style="color:#333;font-size:15px;line-height:1.8;margin:0 0 16px">Volevamo ringraziarti personalmente per [MOTIVO: il tuo acquisto / la tua fiducia / anni di collaborazione].</p><p style="color:#333;font-size:15px;line-height:1.8;margin:0 0 16px">[MESSAGGIO PERSONALE: cosa significa per noi, cosa ci impegniamo a fare, cosa offriamo come segno di riconoscimento]</p><p style="color:#333;font-size:15px;line-height:1.8;margin:0 0 32px">[CHIUSURA: invito a restare in contatto, disponibilità]</p><p style="color:#333;font-size:15px;margin:0 0 4px">Con stima,</p><p style="color:#1a1a2e;font-size:15px;font-weight:700;margin:0">[NOME MITTENTE]</p><p style="color:#888;font-size:13px;margin:4px 0 0">[RUOLO] • [AZIENDA]</p></td></tr></table>',
|
|
1502
|
+
},
|
|
1503
|
+
];
|
|
1504
|
+
|
|
1505
|
+
function emailLoadTemplate(idx) {
|
|
1506
|
+
var t = EMAIL_TEMPLATES[idx];
|
|
1507
|
+
if (!t) return;
|
|
1508
|
+
var subj = document.getElementById('compSubject');
|
|
1509
|
+
if (subj && !subj.value) subj.value = t.subject;
|
|
1510
|
+
if (emailState.quillEditor) {
|
|
1511
|
+
emailState.quillEditor.clipboard.dangerouslyPasteHTML(t.html);
|
|
1512
|
+
}
|
|
1513
|
+
var dd = document.getElementById('compTemplateDrop');
|
|
1514
|
+
if (dd) dd.style.display = 'none';
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
function emailToggleTemplates() {
|
|
1518
|
+
var dd = document.getElementById('compTemplateDrop');
|
|
1519
|
+
if (!dd) return;
|
|
1520
|
+
dd.style.display = dd.style.display === 'none' ? 'block' : 'none';
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1466
1523
|
function emailOpenCompose(opts) {
|
|
1467
1524
|
var pane = document.getElementById('emailPane');
|
|
1468
1525
|
if (!pane) return;
|
|
1469
1526
|
opts = opts || {};
|
|
1470
1527
|
|
|
1471
|
-
var h = '<div style="display:flex;flex-direction:column;height:100%;padding:14px;gap:
|
|
1528
|
+
var h = '<div style="display:flex;flex-direction:column;height:100%;padding:14px;gap:8px">';
|
|
1472
1529
|
h += '<div style="font-size:13px;font-weight:700;color:var(--bright);border-bottom:1px solid var(--border);padding-bottom:8px">Compose</div>';
|
|
1473
1530
|
h += '<input id="compTo" type="text" placeholder="To" value="' + esc(opts.to || '') + '" style="padding:7px 10px;font-size:12px;background:var(--bg);color:var(--fg);border:1px solid var(--border2);border-radius:5px">';
|
|
1474
1531
|
h += '<input id="compCc" type="text" placeholder="Cc" style="padding:7px 10px;font-size:12px;background:var(--bg);color:var(--fg);border:1px solid var(--border2);border-radius:5px">';
|
|
1475
1532
|
h += '<input id="compSubject" type="text" placeholder="Subject" value="' + esc(opts.subject || '') + '" style="padding:7px 10px;font-size:12px;background:var(--bg);color:var(--fg);border:1px solid var(--border2);border-radius:5px">';
|
|
1476
1533
|
h += '<input type="hidden" id="compInReplyTo" value="' + esc(opts.inReplyTo || '') + '">';
|
|
1477
1534
|
h += '<input type="hidden" id="compReplyToId" value="' + esc(opts.replyTo || '') + '">';
|
|
1535
|
+
// Template picker
|
|
1536
|
+
h += '<div style="position:relative">';
|
|
1537
|
+
h += '<button onclick="emailToggleTemplates()" style="padding:5px 12px;font-size:11px;background:var(--bg);color:var(--cyan);border:1px solid var(--cyan);border-radius:5px;cursor:pointer">📄 Template marketing</button>';
|
|
1538
|
+
h += '<div id="compTemplateDrop" style="display:none;position:absolute;top:100%;left:0;z-index:200;background:var(--bg2);border:1px solid var(--border);border-radius:6px;min-width:240px;padding:4px 0;margin-top:4px;box-shadow:0 4px 16px rgba(0,0,0,0.3)">';
|
|
1539
|
+
for (var ti = 0; ti < EMAIL_TEMPLATES.length; ti++) {
|
|
1540
|
+
h += '<div class="tpl-item" onclick="emailLoadTemplate(' + ti + ')" style="padding:9px 16px;font-size:12px;cursor:pointer;color:var(--fg);border-bottom:1px solid var(--border)"><strong>' + esc(EMAIL_TEMPLATES[ti].name) + '</strong><div style="font-size:10px;color:var(--dim);margin-top:2px">' + esc(EMAIL_TEMPLATES[ti].subject.slice(0, 50)) + '</div></div>';
|
|
1541
|
+
}
|
|
1542
|
+
h += '</div></div>';
|
|
1478
1543
|
// Quill editor container
|
|
1479
1544
|
h += '<div id="quillContainer" style="flex:1;min-height:200px;background:#fff;border-radius:5px;overflow:auto"></div>';
|
|
1480
1545
|
// Toolbar
|