/** * Gmail Add-on client for the Malicious Email Scorer FastAPI backend. * * Required Script Properties: * BACKEND_ANALYZE_URL = https://.hf.space/analyze * BACKEND_API_KEY = */ 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( 'Malicious Email Scorer
' + 'Open a Gmail message to analyze its content.' )) .addWidget(CardService.newTextParagraph().setText( 'Ensure the Script Properties ' + '' + CONFIG.ANALYZE_URL_PROPERTY + ' and ' + '' + CONFIG.API_KEY_PROPERTY + ' 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('' + escapeHtml_(verdictUi.label) + '')) .addWidget(CardService.newDecoratedText() .setTopLabel('Final Score') .setText('' + escapeHtml_(score) + ' / 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('' + escapeHtml_(vector.title) + '
' + '' + escapeHtml_(checkId) + '')); section.addWidget(CardService.newTextParagraph().setText( escapeHtml_(vector.explanation) )); if (technicalDescription) { section.addWidget(CardService.newTextParagraph().setText( 'Technical Details: ' + escapeHtml_(technicalDescription) + '' )); } }); if (findings.length > 20) { section.addWidget(CardService.newTextParagraph().setText( 'Showing the first 20 findings out of ' + findings.length + ' to maintain readability.' )); } 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('' + escapeHtml_(title) + '')) .addWidget(CardService.newTextParagraph().setText(escapeHtml_(message))); if (err && err.message) { section.addWidget(CardService.newTextParagraph().setText( 'Technical Details: ' + escapeHtml_(String(err.message).slice(0, 500)) + '' )); } // 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 ? '' + parts.join(' | ') + '' : ''; } 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, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } 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;