Gmail folder auto-advance + /forward command
- Reply-cycle auto-advance: staff reply files the thread to "Awaiting Reply", a customer response files it to "Needs Response" (new GMAIL_LABEL_AWAITING_REPLY / GMAIL_LABEL_NEEDS_RESPONSE labels + autoAdvanceFolder, which only moves threads still in the auto-cycle and leaves hand-filed folders alone) - /forward: forward a ticket's email to another address (handlers/commands/forward.js + forward composition in services/gmail.js) - Tests for the auto-advance cycle; label fixtures updated for the new labels
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
const { google } = require('googleapis');
|
||||
const { CONFIG } = require('../config');
|
||||
const { extractRawEmail, escapeHtml } = require('../utils');
|
||||
const { extractRawEmail, escapeHtml, getCleanBody } = require('../utils');
|
||||
const { getStaffSignatureBlocks } = require('./staffSignature');
|
||||
const { logError } = require('./debugLog');
|
||||
const { readEnvFile } = require('./configPersistence');
|
||||
@@ -49,8 +49,10 @@ function getGmailClient() {
|
||||
|
||||
/**
|
||||
* Re-read REFRESH_TOKEN from .env, update in-memory config, and probe Google.
|
||||
* Used by the internal /gmail/reload endpoint so the weekly reauth chore does
|
||||
* not require a full container restart.
|
||||
* Used by the internal /gmail/reload endpoint so an occasional re-auth (the
|
||||
* OAuth app is published, so the token is long-lived — re-auth is only needed
|
||||
* on revoke/password-change, not on a schedule) does not require a full
|
||||
* container restart.
|
||||
*
|
||||
* Throws if the env file is missing the token, or if the probe call (getProfile)
|
||||
* fails — the caller surfaces the error so the UI can see why.
|
||||
@@ -365,10 +367,171 @@ async function sendGmailReply(threadId, replyText, recipientEmail, subject, mess
|
||||
});
|
||||
}
|
||||
|
||||
// Recursively collect attachment parts (those with a filename + attachmentId)
|
||||
// from a Gmail message payload, at any nesting depth.
|
||||
function collectAttachmentParts(payload) {
|
||||
const out = [];
|
||||
const walk = part => {
|
||||
if (!part) return;
|
||||
if (part.filename && part.body?.attachmentId) {
|
||||
out.push({
|
||||
filename: part.filename,
|
||||
mimeType: part.mimeType || 'application/octet-stream',
|
||||
attachmentId: part.body.attachmentId,
|
||||
size: part.body.size || 0
|
||||
});
|
||||
}
|
||||
if (part.parts) for (const p of part.parts) walk(p);
|
||||
};
|
||||
if (payload?.parts) for (const p of payload.parts) walk(p);
|
||||
else walk(payload);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Forward an entire ticket thread to a third party as a BRAND-NEW email.
|
||||
// The original customer is never looped in: To = target only, no Cc/Bcc, no
|
||||
// threadId, no In-Reply-To/References. Returns counts for the confirmation reply.
|
||||
const FORWARD_MAX_TOTAL_BYTES = 20 * 1024 * 1024; // ~20 MB attachment ceiling
|
||||
const FORWARD_DIVIDER = '-'.repeat(40);
|
||||
|
||||
async function forwardThread(threadId, targetEmail, note = '') {
|
||||
const safeTarget = sanitizeHeaderValue(extractRawEmail(targetEmail || '')).toLowerCase();
|
||||
if (!EMAIL_RE.test(safeTarget)) {
|
||||
const err = new Error(`Invalid forward recipient: ${safeTarget || '(empty)'}`);
|
||||
err.code = 'EBADRECIPIENT';
|
||||
throw err;
|
||||
}
|
||||
|
||||
const gmail = getGmailClient();
|
||||
const thread = await gmail.users.threads.get({ userId: 'me', id: threadId, format: 'full' });
|
||||
const messages = thread.data.messages || [];
|
||||
if (!messages.length) {
|
||||
const err = new Error('Thread has no messages to forward.');
|
||||
err.code = 'EEMPTY';
|
||||
throw err;
|
||||
}
|
||||
|
||||
const firstHeaders = messages[0]?.payload?.headers || [];
|
||||
const baseSubject = firstHeaders.find(h => h.name === 'Subject')?.value || 'No subject';
|
||||
const fwdSubject = sanitizeHeaderValue(`Fwd: ${String(baseSubject).replace(/^(?:\s*Fwd\s*:\s*)+/i, '')}`);
|
||||
const encodedSubject = `=?utf-8?B?${Buffer.from(fwdSubject).toString('base64')}?=`;
|
||||
|
||||
const textBlocks = [];
|
||||
const htmlBlocks = [];
|
||||
const attachments = [];
|
||||
let skipped = 0;
|
||||
let totalBytes = 0;
|
||||
|
||||
for (const msg of messages) {
|
||||
const h = msg.payload?.headers || [];
|
||||
const from = h.find(x => x.name === 'From')?.value || 'Unknown';
|
||||
const date = h.find(x => x.name === 'Date')?.value || '';
|
||||
const body = (getCleanBody(msg.payload) || '').replace(/\r\n/g, '\n').trim();
|
||||
|
||||
textBlocks.push(`From: ${from}\nDate: ${date}\n\n${body}`);
|
||||
htmlBlocks.push(
|
||||
`<div style="margin-bottom:8px;color:#555;font-size:13px;">` +
|
||||
`<strong>From:</strong> ${escapeHtml(from)}<br>` +
|
||||
`<strong>Date:</strong> ${escapeHtml(date)}</div>` +
|
||||
`<div>${escapeHtml(body).replace(/\n/g, '<br>')}</div>`
|
||||
);
|
||||
|
||||
for (const att of collectAttachmentParts(msg.payload)) {
|
||||
if (totalBytes + (att.size || 0) > FORWARD_MAX_TOTAL_BYTES) { skipped++; continue; }
|
||||
try {
|
||||
const res = await gmail.users.messages.attachments.get({
|
||||
userId: 'me', messageId: msg.id, id: att.attachmentId
|
||||
});
|
||||
const std = (res.data.data || '').replace(/-/g, '+').replace(/_/g, '/');
|
||||
const buf = Buffer.from(std, 'base64');
|
||||
totalBytes += buf.length;
|
||||
attachments.push({
|
||||
filename: sanitizeHeaderValue(att.filename).replace(/"/g, ''),
|
||||
mimeType: att.mimeType,
|
||||
base64: buf.toString('base64')
|
||||
});
|
||||
} catch (_) {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const transcriptText = textBlocks.join(`\n\n${FORWARD_DIVIDER}\n\n`);
|
||||
const transcriptHtml = htmlBlocks.join('<hr style="border:none;border-top:1px solid #ddd;margin:16px 0;">');
|
||||
const noteText = note ? `${note}\n\n${FORWARD_DIVIDER}\n\n` : '';
|
||||
const noteHtml = note
|
||||
? `<p>${escapeHtml(note).replace(/\n/g, '<br>')}</p><hr style="border:none;border-top:1px solid #ddd;margin:16px 0;">`
|
||||
: '';
|
||||
|
||||
const stamp = Date.now().toString(16);
|
||||
const altBoundary = 'alt_' + stamp;
|
||||
const altPart = [
|
||||
`--${altBoundary}`,
|
||||
'Content-Type: text/plain; charset="UTF-8"',
|
||||
'',
|
||||
noteText + transcriptText,
|
||||
'',
|
||||
`--${altBoundary}`,
|
||||
'Content-Type: text/html; charset="UTF-8"',
|
||||
'',
|
||||
`<div style="font-family: sans-serif; font-size: 14px; color: #333;">${noteHtml}${transcriptHtml}</div>`,
|
||||
'',
|
||||
`--${altBoundary}--`
|
||||
];
|
||||
|
||||
let topContentType;
|
||||
let bodyLines;
|
||||
if (attachments.length) {
|
||||
const mixBoundary = 'mix_' + stamp;
|
||||
topContentType = `multipart/mixed; boundary="${mixBoundary}"`;
|
||||
bodyLines = [
|
||||
`--${mixBoundary}`,
|
||||
`Content-Type: multipart/alternative; boundary="${altBoundary}"`,
|
||||
'',
|
||||
...altPart,
|
||||
''
|
||||
];
|
||||
for (const a of attachments) {
|
||||
bodyLines.push(
|
||||
`--${mixBoundary}`,
|
||||
`Content-Type: ${a.mimeType}; name="${a.filename}"`,
|
||||
'Content-Transfer-Encoding: base64',
|
||||
`Content-Disposition: attachment; filename="${a.filename}"`,
|
||||
'',
|
||||
...(a.base64.match(/.{1,76}/g) || []),
|
||||
''
|
||||
);
|
||||
}
|
||||
bodyLines.push(`--${mixBoundary}--`);
|
||||
} else {
|
||||
topContentType = `multipart/alternative; boundary="${altBoundary}"`;
|
||||
bodyLines = altPart;
|
||||
}
|
||||
|
||||
// Deliberately omit threadId / In-Reply-To / References so this is a fresh
|
||||
// conversation to the target only — the original sender is never in the loop.
|
||||
const headers = [
|
||||
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
|
||||
`To: ${safeTarget}`,
|
||||
`Subject: ${encodedSubject}`,
|
||||
'MIME-Version: 1.0',
|
||||
`Content-Type: ${topContentType}`
|
||||
];
|
||||
|
||||
const raw = Buffer.from([...headers, '', ...bodyLines].join('\r\n'))
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
|
||||
await gmail.users.messages.send({ userId: 'me', requestBody: { raw } });
|
||||
|
||||
return { messageCount: messages.length, attachmentCount: attachments.length, skipped };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getGmailClient,
|
||||
reloadGmailClient,
|
||||
sendGmailReply,
|
||||
sendTicketClosedEmail,
|
||||
sendTicketNotificationEmail
|
||||
sendTicketNotificationEmail,
|
||||
forwardThread
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user