itay
Update requirements and files for custom models
4dd2b69
Raw
History Blame Contribute Delete
31.8 kB
/**
* Gmail Add-on client for the Malicious Email Scorer FastAPI backend.
*
* Required Script Properties:
* BACKEND_ANALYZE_URL = https://<your-space>.hf.space/analyze
* BACKEND_API_KEY = <same secret configured in the backend>
*/
var CONFIG = {
ANALYZE_URL_PROPERTY: 'BACKEND_ANALYZE_URL',
API_KEY_PROPERTY: 'BACKEND_API_KEY',
MAX_MESSAGE_ID_CHARS: 255,
MAX_SUBJECT_CHARS: 255,
MAX_SENDER_CHARS: 255,
MAX_HTML_CHARS: 100000,
MAX_TEXT_CHARS: 50000
};
var PREF_KEYS = {
CUSTOM_MODE: 'PREF_CUSTOM_MODE',
ENABLE_L1: 'PREF_ENABLE_L1',
ENABLE_L2: 'PREF_ENABLE_L2',
L1_CHECKS: 'PREF_L1_CHECKS',
L2_ENGINES: 'PREF_L2_ENGINES',
L2_MODEL_SOURCE: 'PREF_L2_MODEL_SOURCE'
};
var ALL_L1_CHECKS = ['hidden_content', 'url_analysis', 'prompt_injection', 'structural_anomaly', 'attachment_surface'];
var ALL_L2_ENGINES = ['malicious_intent', 'prompt_injection'];
var VERDICT_UI = {
clean: {
label: 'CLEAN',
color: '#137333',
icon: CardService.Icon.CONFIRMATION_NUMBER_ICON
},
suspicious: {
label: 'SUSPICIOUS',
color: '#B06000',
icon: CardService.Icon.DESCRIPTION
},
malicious: {
label: 'MALICIOUS',
color: '#B3261E',
icon: CardService.Icon.DESCRIPTION
},
unknown: {
label: 'UNKNOWN',
color: '#5F6368',
icon: CardService.Icon.BOOKMARK
}
};
var SEVERITY_DISPLAY = {
critical: 'CRITICAL',
high: 'HIGH',
medium: 'MEDIUM',
low: 'LOW'
};
var ATTACK_VECTOR_DICTIONARY = {
hidden_content: {
title: 'Hidden Content',
explanation: 'Hidden text or elements were found in the email body (e.g., zero-width characters, font color matching background). Attackers use this to bypass spam filters or conceal instructions from AI systems.'
},
url_mismatch: {
title: 'URL Mismatch or Suspicious Link',
explanation: 'A link was found where the display text does not match the actual destination URL, or an obfuscated domain is used. This is a common indicator of phishing attempts.'
},
prompt_injection: {
title: 'AI Prompt Injection',
explanation: 'Text resembling instructions for an AI model was detected. While not directly harmful to a human reader, attackers use this to manipulate automated AI assistants analyzing the email.'
},
phishing: {
title: 'Phishing or Social Engineering',
explanation: 'The email content matches known phishing patterns, such as creating false urgency, impersonating a known entity, or requesting sensitive credentials.'
},
structural_anomaly: {
title: 'Structural Anomaly',
explanation: 'The HTML structure contains unusual patterns, such as excessive nesting, tracking pixels, or anomalous link-to-text ratios often used by threat actors.'
},
attachment_surface: {
title: 'Attachment Risk',
explanation: 'Indicators related to risky attachments were found, such as executable extensions, double extensions, or mismatched MIME types.'
},
unknown: {
title: 'Unclassified Finding',
explanation: 'The system returned a finding that is not mapped to a local explanation. Please review the technical details.'
}
};
var EXACT_CHECK_ID_TO_VECTOR = {
hidden_content: 'hidden_content',
url_mismatch: 'url_mismatch',
prompt_injection: 'prompt_injection',
phishing: 'phishing',
structural_anomaly: 'structural_anomaly',
attachment_surface: 'attachment_surface'
};
var PREFIX_TO_VECTOR = [
{ prefix: 'css_', vector: 'hidden_content' },
{ prefix: 'mso_', vector: 'hidden_content' },
{ prefix: 'zwc_', vector: 'hidden_content' },
{ prefix: 'comment_', vector: 'hidden_content' },
{ prefix: 'color_', vector: 'hidden_content' },
{ prefix: 'url_', vector: 'url_mismatch' },
{ prefix: 'domain_', vector: 'url_mismatch' },
{ prefix: 'link_', vector: 'url_mismatch' },
{ prefix: 'attach_', vector: 'attachment_surface' },
{ prefix: 'attachment_', vector: 'attachment_surface' },
{ prefix: 'entropy_', vector: 'structural_anomaly' },
{ prefix: 'pixel_', vector: 'structural_anomaly' },
{ prefix: 'linkratio_', vector: 'structural_anomaly' },
{ prefix: 'structure_', vector: 'structural_anomaly' },
{ prefix: 'structural_', vector: 'structural_anomaly' },
{ prefix: 'pi_', vector: 'prompt_injection' },
{ prefix: 'prompt_', vector: 'prompt_injection' },
{ prefix: 'instruction_', vector: 'prompt_injection' },
{ prefix: 'phishing_', vector: 'phishing' },
{ prefix: 'semantic_', vector: 'phishing' }
];
// ---- Preferences Persistence ------------------------------------------------
function sanitizePreferenceArray_(value, allowedValues, fallback) {
if (!Array.isArray(value)) {
return fallback.slice();
}
var sanitized = [];
value.forEach(function(item) {
if (allowedValues.indexOf(item) !== -1 && sanitized.indexOf(item) === -1) {
sanitized.push(item);
}
});
return sanitized;
}
function normalizeBooleanInput_(formInput, fieldName, fallback) {
if (!formInput || formInput[fieldName] === undefined || formInput[fieldName] === null) {
return fallback;
}
return formInput[fieldName] === 'true';
}
function normalizeSelectionInput_(formInput, fieldName, allowedValues, fallback) {
if (!formInput || formInput[fieldName] === undefined || formInput[fieldName] === null) {
return fallback.slice();
}
var selected = formInput[fieldName];
if (!Array.isArray(selected)) selected = [selected];
return sanitizePreferenceArray_(selected, allowedValues, []);
}
function normalizeDropdownInput_(formInput, fieldName, allowedValues, fallback) {
if (!formInput || formInput[fieldName] === undefined || formInput[fieldName] === null) {
return fallback;
}
var value = formInput[fieldName];
if (Array.isArray(value)) value = value[0];
return allowedValues.indexOf(value) !== -1 ? value : fallback;
}
function preferencesFromFormInput_(formInput, fallback) {
var prefs = fallback || loadPreferences_();
var customMode = normalizeBooleanInput_(formInput, 'custom_mode', prefs.customMode);
var granularControlsRendered = !!(
formInput &&
(
formInput.l1_checks !== undefined ||
formInput.l2_engines !== undefined ||
formInput.l2_model_source !== undefined
)
);
return {
customMode: customMode,
enableL1: normalizeBooleanInput_(formInput, 'enable_l1', prefs.enableL1),
enableL2: normalizeBooleanInput_(formInput, 'enable_l2', prefs.enableL2),
l1Checks: customMode
? normalizeSelectionInput_(
formInput,
'l1_checks',
ALL_L1_CHECKS,
granularControlsRendered ? [] : prefs.l1Checks
)
: prefs.l1Checks.slice(),
l2Engines: customMode
? normalizeSelectionInput_(
formInput,
'l2_engines',
ALL_L2_ENGINES,
granularControlsRendered ? [] : prefs.l2Engines
)
: prefs.l2Engines.slice(),
l2ModelSource: customMode
? normalizeDropdownInput_(formInput, 'l2_model_source', ['ots', 'custom'], prefs.l2ModelSource)
: prefs.l2ModelSource
};
}
/**
* Load saved user preferences from UserProperties with safe defaults.
*/
function loadPreferences_() {
var defaults = {
customMode: false,
enableL1: true,
enableL2: false,
l1Checks: ALL_L1_CHECKS.slice(),
l2Engines: ALL_L2_ENGINES.slice(),
l2ModelSource: 'ots'
};
try {
var userProps = PropertiesService.getUserProperties();
var customMode = userProps.getProperty(PREF_KEYS.CUSTOM_MODE);
var enableL1 = userProps.getProperty(PREF_KEYS.ENABLE_L1);
var enableL2 = userProps.getProperty(PREF_KEYS.ENABLE_L2);
var l1Checks = userProps.getProperty(PREF_KEYS.L1_CHECKS);
var l2Engines = userProps.getProperty(PREF_KEYS.L2_ENGINES);
var l2ModelSource = userProps.getProperty(PREF_KEYS.L2_MODEL_SOURCE);
if (customMode !== null) defaults.customMode = (customMode === 'true');
if (enableL1 !== null) defaults.enableL1 = (enableL1 === 'true');
if (enableL2 !== null) defaults.enableL2 = (enableL2 === 'true');
if (l1Checks !== null) {
defaults.l1Checks = sanitizePreferenceArray_(
JSON.parse(l1Checks),
ALL_L1_CHECKS,
ALL_L1_CHECKS
);
}
if (l2Engines !== null) {
defaults.l2Engines = sanitizePreferenceArray_(
JSON.parse(l2Engines),
ALL_L2_ENGINES,
ALL_L2_ENGINES
);
}
if (l2ModelSource !== null && (l2ModelSource === 'ots' || l2ModelSource === 'custom')) {
defaults.l2ModelSource = l2ModelSource;
}
} catch (e) {
// Corrupted prefs — silently reset to defaults
}
return defaults;
}
/**
* Persist current form state to UserProperties.
*/
function savePreferences_(formInput) {
savePreferenceState_(preferencesFromFormInput_(formInput, loadPreferences_()));
}
/**
* Persist normalized preference state to UserProperties.
*/
function savePreferenceState_(prefs) {
var userProps = PropertiesService.getUserProperties();
userProps.setProperties({
PREF_CUSTOM_MODE: String(prefs.customMode),
PREF_ENABLE_L1: String(prefs.enableL1),
PREF_ENABLE_L2: String(prefs.enableL2),
PREF_L1_CHECKS: JSON.stringify(prefs.l1Checks),
PREF_L2_ENGINES: JSON.stringify(prefs.l2Engines),
PREF_L2_MODEL_SOURCE: prefs.l2ModelSource
});
}
// ---- Granular Config Builders -----------------------------------------------
/**
* Build l1_config from form input, or null if all defaults.
*/
function buildL1Config_(formInput) {
var selected = preferencesFromFormInput_(formInput, loadPreferences_()).l1Checks;
// If all 5 checks are selected, return null (legacy optimization)
if (selected.length === ALL_L1_CHECKS.length) {
var allSelected = ALL_L1_CHECKS.every(function(check) {
return selected.indexOf(check) !== -1;
});
if (allSelected) return null;
}
var config = {};
ALL_L1_CHECKS.forEach(function(check) {
config[check] = selected.indexOf(check) !== -1;
});
return config;
}
/**
* Build l2_config from form input, or null if all defaults.
*/
function buildL2Config_(formInput) {
var prefs = preferencesFromFormInput_(formInput, loadPreferences_());
var selected = prefs.l2Engines;
var modelSource = prefs.l2ModelSource;
// If both engines selected and model source is 'ots', return null
var bothSelected = ALL_L2_ENGINES.every(function(engine) {
return selected.indexOf(engine) !== -1;
});
if (bothSelected && modelSource === 'ots') return null;
var config = {
model_source: modelSource
};
ALL_L2_ENGINES.forEach(function(engine) {
config[engine] = selected.indexOf(engine) !== -1;
});
return config;
}
// ---- Card Sections ----------------------------------------------------------
/**
* Returns CardSection with the Custom Analysis Mode switch.
*/
function buildModeSection_(prefs) {
var modeSwitch = CardService.newSwitch()
.setFieldName('custom_mode')
.setValue('true')
.setSelected(prefs.customMode)
.setOnChangeAction(CardService.newAction().setFunctionName('onCustomModeToggle'));
var modeText = CardService.newDecoratedText()
.setText('Custom Analysis Mode')
.setBottomLabel('Enable granular check selection')
.setSwitchControl(modeSwitch);
return CardService.newCardSection()
.setHeader('Analysis Mode')
.addWidget(modeText);
}
/**
* Returns CardSection with L1 check checkboxes.
*/
function buildL1GranularSection_(prefs) {
var checkboxes = CardService.newSelectionInput()
.setType(CardService.SelectionInputType.CHECK_BOX)
.setFieldName('l1_checks')
.setOnChangeAction(CardService.newAction().setFunctionName('onPreferencesChanged'));
var labels = {
hidden_content: 'Hidden Content',
url_analysis: 'URL Analysis',
prompt_injection: 'Prompt Injection',
structural_anomaly: 'Structural Anomaly',
attachment_surface: 'Attachment Surface'
};
ALL_L1_CHECKS.forEach(function(check) {
var selected = prefs.l1Checks.indexOf(check) !== -1;
checkboxes.addItem(labels[check], check, selected);
});
return CardService.newCardSection()
.setHeader('L1 Heuristic Checks')
.addWidget(checkboxes);
}
/**
* Returns CardSection with L2 engine checkboxes + model source dropdown.
*/
function buildL2GranularSection_(prefs) {
var engineCheckboxes = CardService.newSelectionInput()
.setType(CardService.SelectionInputType.CHECK_BOX)
.setFieldName('l2_engines')
.setOnChangeAction(CardService.newAction().setFunctionName('onPreferencesChanged'));
var engineLabels = {
malicious_intent: 'Malicious Intent',
prompt_injection: 'Prompt Injection'
};
ALL_L2_ENGINES.forEach(function(engine) {
var selected = prefs.l2Engines.indexOf(engine) !== -1;
engineCheckboxes.addItem(engineLabels[engine], engine, selected);
});
var modelSourceDropdown = CardService.newSelectionInput()
.setType(CardService.SelectionInputType.DROPDOWN)
.setFieldName('l2_model_source')
.setOnChangeAction(CardService.newAction().setFunctionName('onPreferencesChanged'))
.addItem('Off-the-Shelf (HuggingFace)', 'ots', prefs.l2ModelSource === 'ots')
.addItem('Custom Fine-Tuned', 'custom', prefs.l2ModelSource === 'custom');
var modelSourceLabel = CardService.newDecoratedText()
.setText('Model Source')
.setBottomLabel('Which model weights to use for L2 inference');
return CardService.newCardSection()
.setHeader('L2 Semantic Classifiers')
.addWidget(engineCheckboxes)
.addWidget(CardService.newDivider())
.addWidget(modelSourceLabel)
.addWidget(modelSourceDropdown);
}
// ---- Main Card Builder ------------------------------------------------------
/**
* Gmail contextual trigger. Runs when a user opens an email.
* Displays the configuration card with saved preferences.
*/
function onGmailMessageOpen(e) {
try {
validateGmailEvent_(e);
var prefs = loadPreferences_();
return buildScanConfigCard_(e, prefs);
} catch (err) {
return buildErrorCard_('Cannot initialize scan', friendlyErrorMessage_(err), err);
}
}
/**
* Homepage shown when no Gmail message context is active.
*/
function buildHomepageCard() {
var section = CardService.newCardSection()
.addWidget(CardService.newTextParagraph().setText(
'<b>Malicious Email Scorer</b><br>' +
'Open a Gmail message to analyze its content.'
))
.addWidget(CardService.newTextParagraph().setText(
'Ensure the Script Properties ' +
'<b>' + CONFIG.ANALYZE_URL_PROPERTY + '</b> and ' +
'<b>' + CONFIG.API_KEY_PROPERTY + '</b> are configured.'
));
return CardService.newCardBuilder()
.setHeader(CardService.newCardHeader().setTitle('Malicious Email Scorer'))
.addSection(section)
.build();
}
/**
* Builds the configuration card with optional granular sections.
*/
function buildScanConfigCard_(e, prefs) {
if (!prefs) prefs = loadPreferences_();
var card = CardService.newCardBuilder()
.setHeader(CardService.newCardHeader().setTitle('Scan Configuration'));
// Section 1: Analysis Mode toggle
card.addSection(buildModeSection_(prefs));
// Section 2: Layer toggles
var l1Switch = CardService.newSwitch()
.setFieldName('enable_l1')
.setValue('true')
.setSelected(prefs.enableL1)
.setOnChangeAction(CardService.newAction().setFunctionName('onPreferencesChanged'));
var l1DecoratedText = CardService.newDecoratedText()
.setText('Layer 1: Structural Analysis')
.setBottomLabel('Heuristics: Domains, links, and HTML structure')
.setSwitchControl(l1Switch);
var l2Switch = CardService.newSwitch()
.setFieldName('enable_l2')
.setValue('true')
.setSelected(prefs.enableL2)
.setOnChangeAction(CardService.newAction().setFunctionName('onPreferencesChanged'));
var l2DecoratedText = CardService.newDecoratedText()
.setText('Layer 2: Semantic Analysis')
.setBottomLabel('AI Models: Malicious intent and prompt injection')
.setSwitchControl(l2Switch);
var settingsSection = CardService.newCardSection()
.setHeader('Evaluation Layers')
.addWidget(l1DecoratedText)
.addWidget(l2DecoratedText);
card.addSection(settingsSection);
// Sections 3 & 4: Granular config (only when custom mode ON)
if (prefs.customMode) {
card.addSection(buildL1GranularSection_(prefs));
card.addSection(buildL2GranularSection_(prefs));
}
// Scan button section
var action = CardService.newAction().setFunctionName('scanEmailAction');
var button = CardService.newTextButton()
.setText('Scan Email')
.setTextButtonStyle(CardService.TextButtonStyle.FILLED)
.setOnClickAction(action);
var buttonSection = CardService.newCardSection().addWidget(button);
card.addSection(buttonSection);
return card.build();
}
/**
* Triggered by custom_mode switch change; rebuilds card.
*/
function onCustomModeToggle(e) {
var prefs = preferencesFromFormInput_(e.formInput || {}, loadPreferences_());
savePreferenceState_(prefs);
var newCard = buildScanConfigCard_(e, prefs);
return CardService.newActionResponseBuilder()
.setNavigation(CardService.newNavigation().updateCard(newCard))
.build();
}
/**
* Triggered by any preference control change; saves and rebuilds from state.
*/
function onPreferencesChanged(e) {
var prefs = preferencesFromFormInput_(e.formInput || {}, loadPreferences_());
savePreferenceState_(prefs);
var newCard = buildScanConfigCard_(e, prefs);
return CardService.newActionResponseBuilder()
.setNavigation(CardService.newNavigation().updateCard(newCard))
.build();
}
/**
* Action triggered when the "Scan Email" button is clicked.
*/
function scanEmailAction(e) {
var prefs = preferencesFromFormInput_(e.formInput || {}, loadPreferences_());
var enableL1 = prefs.enableL1;
var enableL2 = prefs.enableL2;
var customMode = prefs.customMode;
// Prevent empty scans
if (!enableL1 && !enableL2) {
return CardService.newActionResponseBuilder()
.setNotification(CardService.newNotification()
.setText('Error: At least one evaluation layer must be enabled.'))
.build();
}
// Custom mode: validate at least one check/engine is active
if (customMode) {
var l1Checks = prefs.l1Checks;
var l2Engines = prefs.l2Engines;
var hasActiveL1 = enableL1 && l1Checks.length > 0;
var hasActiveL2 = enableL2 && l2Engines.length > 0;
if (!hasActiveL1 && !hasActiveL2) {
return CardService.newActionResponseBuilder()
.setNotification(CardService.newNotification()
.setText('Error: At least one check or engine must be active.'))
.build();
}
}
try {
// Save preferences on scan. Kept inside the error boundary so
// PropertiesService quota/service failures produce a normal error card.
savePreferenceState_(prefs);
validateGmailEvent_(e);
GmailApp.setCurrentMessageAccessToken(e.gmail.accessToken);
var message = GmailApp.getMessageById(e.gmail.messageId);
// Build granular config objects (null = legacy/all-defaults)
var l1Config = null;
var l2Config = null;
if (customMode) {
l1Config = buildL1Config_(e.formInput);
l2Config = buildL2Config_(e.formInput);
}
var result = analyzeCurrentMessage_(message, e.gmail.messageId, enableL1, enableL2, l1Config, l2Config);
var resultCard = buildResultCard_(result);
// Push the result card on top so the user can navigate back to settings
return CardService.newActionResponseBuilder()
.setNavigation(CardService.newNavigation().pushCard(resultCard))
.build();
} catch (err) {
var errorCard = buildErrorCard_('Scan Failed', friendlyErrorMessage_(err), err);
return CardService.newActionResponseBuilder()
.setNavigation(CardService.newNavigation().pushCard(errorCard))
.build();
}
}
/**
* Extracts, truncates, and sends the current Gmail message to the backend.
*/
function analyzeCurrentMessage_(message, eventMessageId, enableL1, enableL2, l1Config, l2Config) {
var props = PropertiesService.getScriptProperties();
var analyzeUrl = trim_(props.getProperty(CONFIG.ANALYZE_URL_PROPERTY));
var apiKey = trim_(props.getProperty(CONFIG.API_KEY_PROPERTY));
if (!analyzeUrl || !apiKey) {
throw new ConfigurationError(
'Missing required Script Properties: ' +
CONFIG.ANALYZE_URL_PROPERTY + ' and/or ' + CONFIG.API_KEY_PROPERTY
);
}
var payload = {
message_id: truncate_(eventMessageId || message.getId(), CONFIG.MAX_MESSAGE_ID_CHARS),
subject: truncate_(message.getSubject(), CONFIG.MAX_SUBJECT_CHARS),
sender: truncate_(message.getFrom(), CONFIG.MAX_SENDER_CHARS),
body_html: truncate_(message.getBody(), CONFIG.MAX_HTML_CHARS),
body_text: truncate_(message.getPlainBody(), CONFIG.MAX_TEXT_CHARS),
enable_l1: enableL1,
enable_l2: enableL2
};
// Conditionally add granular config (only when non-null)
if (l1Config !== null && l1Config !== undefined) {
payload.l1_config = l1Config;
}
if (l2Config !== null && l2Config !== undefined) {
payload.l2_config = l2Config;
}
var response;
try {
response = UrlFetchApp.fetch(analyzeUrl, {
method: 'post',
contentType: 'application/json',
headers: {
'X-API-Key': apiKey
},
payload: JSON.stringify(payload),
muteHttpExceptions: true
});
} catch (err) {
throw new NetworkError('UrlFetchApp failed: ' + err.message);
}
var statusCode = response.getResponseCode();
var responseText = response.getContentText() || '';
if (statusCode !== 200) {
throw new BackendHttpError(statusCode, responseText);
}
try {
return JSON.parse(responseText);
} catch (err) {
throw new MalformedResponseError('Backend returned non-JSON response.');
}
}
/**
* Builds the result card from AnalyzeResponse.
*/
function buildResultCard_(result) {
var verdictKey = normalizeVerdict_(result && result.verdict);
var verdictUi = VERDICT_UI[verdictKey] || VERDICT_UI.unknown;
var score = formatScore_(result && result.final_score);
var findings = Array.isArray(result && result.reasoning) ? result.reasoning : [];
var card = CardService.newCardBuilder()
.setHeader(CardService.newCardHeader()
.setTitle('Malicious Email Scorer')
.setSubtitle('Analysis Results'));
var summarySection = CardService.newCardSection()
.addWidget(CardService.newDecoratedText()
.setStartIcon(CardService.newIconImage().setIcon(verdictUi.icon))
.setTopLabel('Verdict')
.setText('<font color="' + verdictUi.color + '"><b>' + escapeHtml_(verdictUi.label) + '</b></font>'))
.addWidget(CardService.newDecoratedText()
.setTopLabel('Final Score')
.setText('<b>' + escapeHtml_(score) + '</b> / 100'));
var metadata = buildMetadataLine_(result);
if (metadata) {
summarySection.addWidget(CardService.newTextParagraph().setText(metadata));
}
card.addSection(summarySection);
if (!findings.length) {
card.addSection(CardService.newCardSection()
.setHeader('Findings')
.addWidget(CardService.newTextParagraph().setText(
'No specific triggers found. Please continue to exercise caution if the email requests sensitive actions.'
)));
return card.build();
}
card.addSection(buildFindingsSection_(findings));
return card.build();
}
/**
* Builds finding widgets with English explanations.
*/
function buildFindingsSection_(findings) {
var section = CardService.newCardSection().setHeader('Findings & Explanations');
findings.slice(0, 20).forEach(function(finding) {
var checkId = String(finding.check_id || 'unknown');
var severity = normalizeSeverity_(finding.severity);
var vectorKey = resolveAttackVector_(checkId);
var vector = ATTACK_VECTOR_DICTIONARY[vectorKey] || ATTACK_VECTOR_DICTIONARY.unknown;
var technicalDescription = String(finding.description || '').slice(0, 500);
section.addWidget(CardService.newDecoratedText()
.setTopLabel('Severity: ' + severityDisplay_(severity))
.setText('<b>' + escapeHtml_(vector.title) + '</b><br>' +
'<font color="#5F6368">' + escapeHtml_(checkId) + '</font>'));
section.addWidget(CardService.newTextParagraph().setText(
escapeHtml_(vector.explanation)
));
if (technicalDescription) {
section.addWidget(CardService.newTextParagraph().setText(
'<font color="#5F6368"><b>Technical Details:</b> ' +
escapeHtml_(technicalDescription) +
'</font>'
));
}
});
if (findings.length > 20) {
section.addWidget(CardService.newTextParagraph().setText(
'<font color="#5F6368">Showing the first 20 findings out of ' +
findings.length +
' to maintain readability.</font>'
));
}
return section;
}
/**
* Builds a user-friendly error card.
*/
function buildErrorCard_(title, message, err) {
var section = CardService.newCardSection()
.addWidget(CardService.newDecoratedText()
.setStartIcon(CardService.newIconImage().setIcon(CardService.Icon.DESCRIPTION))
.setText('<b>' + escapeHtml_(title) + '</b>'))
.addWidget(CardService.newTextParagraph().setText(escapeHtml_(message)));
if (err && err.message) {
section.addWidget(CardService.newTextParagraph().setText(
'<font color="#5F6368"><b>Technical Details:</b> ' +
escapeHtml_(String(err.message).slice(0, 500)) +
'</font>'
));
}
// To retry, we pop the current error card to go back to the settings card
var retryAction = CardService.newAction().setFunctionName('popToRoot_');
section.addWidget(CardService.newTextButton()
.setText('Back to Settings')
.setTextButtonStyle(CardService.TextButtonStyle.FILLED)
.setOnClickAction(retryAction));
return CardService.newCardBuilder()
.setHeader(CardService.newCardHeader()
.setTitle('Malicious Email Scorer')
.setSubtitle('Scan Error'))
.addSection(section)
.build();
}
/** Action to pop back to the configuration card */
function popToRoot_(e) {
return CardService.newActionResponseBuilder()
.setNavigation(CardService.newNavigation().popToRoot())
.build();
}
/**
* Converts backend/network failures to clear English user messages.
*/
function friendlyErrorMessage_(err) {
if (err instanceof ConfigurationError) {
return 'The Add-on is not fully configured. Please ensure BACKEND_ANALYZE_URL and BACKEND_API_KEY are set in Script Properties.';
}
if (err instanceof BackendHttpError) {
if (err.statusCode === 400) {
return 'The server rejected the request. Please ensure at least one evaluation layer is enabled.';
}
if (err.statusCode === 401) {
return 'API Key is missing or invalid. Please verify the BACKEND_API_KEY matches the server configuration.';
}
if (err.statusCode === 413) {
return 'The server rejected the request because the payload is too large.';
}
if (err.statusCode === 422) {
return 'The server rejected the request format. Schema validation failed.';
}
if (err.statusCode === 503) {
// Check if this is a custom model unavailability error
if (err.responseText && err.responseText.indexOf('Custom models') !== -1) {
return "Custom models are not available on this server. Switch Model Source to 'Off-the-Shelf' and retry.";
}
return 'The server is currently unavailable or experiencing a cold start. Please try again in a few moments.';
}
if (err.statusCode === 504 || err.statusCode >= 500) {
return 'The server is currently unavailable or experiencing a cold start. Please try again in a few moments.';
}
return 'The server returned an HTTP ' + err.statusCode + ' error. Please check server logs.';
}
if (err instanceof NetworkError) {
return 'Could not establish a connection to the server. There may be a network issue or a prolonged Cold Start delay.';
}
if (err instanceof MalformedResponseError) {
return 'The server returned an invalid non-JSON response. Please check that the URL points directly to the /analyze endpoint.';
}
return 'An unexpected error occurred during the scan. Please check Apps Script logs.';
}
function validateGmailEvent_(e) {
if (!e || !e.gmail || !e.gmail.messageId || !e.gmail.accessToken) {
throw new Error('Missing Gmail message context or access token.');
}
}
function resolveAttackVector_(checkId) {
if (EXACT_CHECK_ID_TO_VECTOR[checkId]) {
return EXACT_CHECK_ID_TO_VECTOR[checkId];
}
for (var i = 0; i < PREFIX_TO_VECTOR.length; i++) {
if (checkId.indexOf(PREFIX_TO_VECTOR[i].prefix) === 0) {
return PREFIX_TO_VECTOR[i].vector;
}
}
if (checkId.indexOf('phish') !== -1) {
return 'phishing';
}
if (checkId.indexOf('prompt') !== -1 || checkId.indexOf('injection') !== -1) {
return 'prompt_injection';
}
if (checkId.indexOf('attach') !== -1) {
return 'attachment_surface';
}
if (checkId.indexOf('url') !== -1 || checkId.indexOf('domain') !== -1) {
return 'url_mismatch';
}
if (checkId.indexOf('hidden') !== -1 || checkId.indexOf('css') !== -1) {
return 'hidden_content';
}
return 'unknown';
}
function buildMetadataLine_(result) {
var parts = [];
if (result && result.winning_layer) {
parts.push('Winning Layer: ' + escapeHtml_(String(result.winning_layer)));
}
if (result && result.elapsed_ms !== null && result.elapsed_ms !== undefined) {
parts.push('Processing Time: ' + escapeHtml_(String(result.elapsed_ms)) + 'ms');
}
if (result && result.was_short_circuited === true) {
parts.push('Short-circuited (High Risk)');
}
return parts.length ? '<font color="#5F6368">' + parts.join(' | ') + '</font>' : '';
}
function normalizeVerdict_(verdict) {
var value = String(verdict || '').toLowerCase();
if (value === 'clean' || value === 'suspicious' || value === 'malicious') {
return value;
}
return 'unknown';
}
function normalizeSeverity_(severity) {
var value = String(severity || '').toLowerCase();
if (value === 'critical' || value === 'high' || value === 'medium' || value === 'low') {
return value;
}
return 'medium';
}
function severityDisplay_(severity) {
return SEVERITY_DISPLAY[severity] || SEVERITY_DISPLAY.medium;
}
function formatScore_(score) {
var number = Number(score);
if (isNaN(number)) {
return 'N/A';
}
return String(Math.round(number * 100) / 100);
}
function truncate_(value, maxChars) {
return String(value || '').slice(0, maxChars);
}
function trim_(value) {
return String(value || '').trim();
}
function escapeHtml_(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function ConfigurationError(message) {
this.name = 'ConfigurationError';
this.message = message;
}
ConfigurationError.prototype = Object.create(Error.prototype);
ConfigurationError.prototype.constructor = ConfigurationError;
function BackendHttpError(statusCode, responseText) {
this.name = 'BackendHttpError';
this.statusCode = statusCode;
this.responseText = responseText || '';
this.message = 'Backend returned HTTP ' + statusCode + ': ' + this.responseText.slice(0, 500);
}
BackendHttpError.prototype = Object.create(Error.prototype);
BackendHttpError.prototype.constructor = BackendHttpError;
function NetworkError(message) {
this.name = 'NetworkError';
this.message = message;
}
NetworkError.prototype = Object.create(Error.prototype);
NetworkError.prototype.constructor = NetworkError;
function MalformedResponseError(message) {
this.name = 'MalformedResponseError';
this.message = message;
}
MalformedResponseError.prototype = Object.create(Error.prototype);
MalformedResponseError.prototype.constructor = MalformedResponseError;