UNPKG

email-scrubber-core

Version:

A privacy-focused email sanitizer that removes trackers and cleans URLs, powered by the ClearURLs ruleset.

310 lines 10.5 kB
/** * Default configuration for the TrackerPixelRemover. */ const DEFAULT_OPTIONS = { maxPixelSize: 2, trackingDomains: [ "google-analytics.com", "googletagmanager.com", "doubleclick.net", "facebook.com", "connect.facebook.net", "scorecardresearch.com", "quantserve.com", "outbrain.com", "taboola.com", "adsystem.com", "amazon-adsystem.com", "googlesyndication.com", "googleadservices.com", "twitter.com", "linkedin.com", "pinterest.com", "snapchat.com", "tiktok.com", "hubspot.com", "mailchimp.com", "constantcontact.com", "sendgrid.net", "mailgun.org", "createsend.com", "campaign-monitor.com", "aweber.com", "getresponse.com", "convertkit.com", "activecampaign.com", "drip.com", "klaviyo.com", "omnisend.com", "sendinblue.com", "emailoctopus.com", "moosend.com", "pardot.com", "marketo.com", "eloqua.com", "salesforce.com", ], trackingParams: [ "utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term", "fbclid", "gclid", "msclkid", "twclid", "li_fat_id", "mc_cid", "mc_eid", "_hsenc", "_hsmi", "mkt_tok", "vero_id", "vero_conv", "email_id", "recipient_id", "list_id", "campaign_id", "message_id", "subscriber_id", "tracking_id", "pixel_id", "beacon_id", ], removeNoAltImages: true, removeTransparentImages: true, }; /** * A class for removing tracking pixels from email HTML content. */ export class TrackerPixelRemover { options; /** * Creates an instance of the TrackerPixelRemover. * @param options Configuration options for the tracker pixel remover. */ constructor(options = {}) { this.options = { ...DEFAULT_OPTIONS, ...options }; } /** * Removes tracking pixels from a DOM document. * @param document The DOM document to clean. * @returns The number of tracking pixels removed. */ clean(document) { // Find all img elements const images = document.querySelectorAll("img"); const imagesToRemove = []; for (const img of Array.from(images)) { if (this.isTrackingPixel(img)) { imagesToRemove.push(img); } } // Remove identified tracking pixels for (const img of imagesToRemove) { img.remove(); } return imagesToRemove.length; } /** * Determines if an image element is likely a tracking pixel. * @param img The image element to analyze. * @returns True if the image is likely a tracking pixel. */ isTrackingPixel(img) { // Definitive tracking indicators - remove if any of these are true // Check if image source contains tracking domains if (this.hasTrackingDomain(img)) { return true; } // Check if image URL contains tracking parameters if (this.hasTrackingParameters(img)) { return true; } // Check if image is hidden via CSS if (this.isHidden(img)) { return true; } // Less definitive indicators - need multiple signals const hasSmallDimensions = this.hasTrackingPixelDimensions(img); const hasNoAlt = this.options.removeNoAltImages && this.hasNoAltText(img); const isTransparent = this.options.removeTransparentImages && this.isTransparent(img); // Remove if it has small dimensions and no alt text if (hasSmallDimensions && hasNoAlt) { return true; } // Remove if it's transparent and has no alt text if (isTransparent && hasNoAlt) { return true; } // Remove if it has small dimensions and is transparent if (hasSmallDimensions && isTransparent) { return true; } return false; } /** * Checks if an image has tracking pixel dimensions. * @param img The image element to check. * @returns True if the image has tracking pixel dimensions. */ hasTrackingPixelDimensions(img) { const width = this.getDimension(img.getAttribute("width")); const height = this.getDimension(img.getAttribute("height")); // Check explicit width/height attributes if (width !== null && height !== null) { return (width <= this.options.maxPixelSize && height <= this.options.maxPixelSize); } // Check style attribute const style = img.getAttribute("style"); if (style) { const widthMatch = style.match(/width\s*:\s*(\d+)px/i); const heightMatch = style.match(/height\s*:\s*(\d+)px/i); if (widthMatch && heightMatch) { const styleWidth = parseInt(widthMatch[1], 10); const styleHeight = parseInt(heightMatch[1], 10); return (styleWidth <= this.options.maxPixelSize && styleHeight <= this.options.maxPixelSize); } } return false; } /** * Checks if an image source contains a known tracking domain. * @param img The image element to check. * @returns True if the image source contains a tracking domain. */ hasTrackingDomain(img) { const src = img.getAttribute("src"); if (!src) { return false; } try { const url = new URL(src); const hostname = url.hostname.toLowerCase(); return this.options.trackingDomains.some((domain) => hostname === domain || hostname.endsWith(`.${domain}`)); } catch { // If URL parsing fails, check if any tracking domain is in the src string const srcLower = src.toLowerCase(); return this.options.trackingDomains.some((domain) => srcLower.includes(domain)); } } /** * Checks if an image URL contains tracking parameters. * @param img The image element to check. * @returns True if the image URL contains tracking parameters. */ hasTrackingParameters(img) { const src = img.getAttribute("src"); if (!src) { return false; } try { const url = new URL(src); const params = url.searchParams; for (const trackingParam of this.options.trackingParams) { if (params.has(trackingParam)) { return true; } } } catch { // If URL parsing fails, check if any tracking parameter is in the src string const srcLower = src.toLowerCase(); return this.options.trackingParams.some((param) => srcLower.includes(`${param}=`)); } return false; } /** * Checks if an image has no alt text. * @param img The image element to check. * @returns True if the image has no alt text. */ hasNoAltText(img) { const alt = img.getAttribute("alt"); // Consider it "no alt text" if the alt attribute is missing, empty, or only whitespace return alt === null || alt.trim() === ""; } /** * Checks if an image appears to be transparent. * @param img The image element to check. * @returns True if the image appears to be transparent. */ isTransparent(img) { const src = img.getAttribute("src"); if (!src) { return false; } // Check for transparent pixel data URLs const transparentGif = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; const transparentPng = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; if (src === transparentGif || src === transparentPng) { return true; } // Check for other transparent data URLs if (src.startsWith("data:image/") && src.includes("transparent")) { return true; } // Check style for opacity const style = img.getAttribute("style"); if (style) { const opacityMatch = style.match(/opacity\s*:\s*([\d.]+)/i); if (opacityMatch) { const opacity = parseFloat(opacityMatch[1]); return opacity === 0; } } return false; } /** * Checks if an image is hidden via CSS. * @param img The image element to check. * @returns True if the image is hidden. */ isHidden(img) { const style = img.getAttribute("style"); if (!style) { return false; } const styleLower = style.toLowerCase(); // Check for display: none if (styleLower.includes("display:none") || styleLower.includes("display: none")) { return true; } // Check for visibility: hidden if (styleLower.includes("visibility:hidden") || styleLower.includes("visibility: hidden")) { return true; } // Check for position: absolute with negative coordinates (common hiding technique) if (styleLower.includes("position:absolute") || styleLower.includes("position: absolute")) { const leftMatch = style.match(/left\s*:\s*(-?\d+)px/i); const topMatch = style.match(/top\s*:\s*(-?\d+)px/i); if (leftMatch && parseInt(leftMatch[1], 10) < -1000) { return true; } if (topMatch && parseInt(topMatch[1], 10) < -1000) { return true; } } return false; } /** * Parses a dimension string and returns a number or null. * @param dimension The dimension string to parse. * @returns The parsed dimension or null if invalid. */ getDimension(dimension) { if (!dimension) { return null; } const match = dimension.match(/^(\d+)(?:px)?$/i); return match ? parseInt(match[1], 10) : null; } } //# sourceMappingURL=TrackerPixelRemover.js.map