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:
2026-06-05 02:46:50 +00:00
parent 0fcffe8d33
commit 6bae3e79b1
10 changed files with 410 additions and 10 deletions

View File

@@ -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
};