Spaces:
Sleeping
Sleeping
| /** | |
| * 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, '&') | |
| .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; | |