Is Mixmax: AI-Powered Sales Engagement, Email Tracking and Meeting Scheduling safe?

High risk

Mixmax is high risk. Mixmax requested selector/watcher JSON from extension.mixmax.com seconds after loading Gmail. The same loader runs on Gmail, Salesforce, LinkedIn: it fetches two config files into page globals, then loads the module watching page elements.…

Mixmax, Inc.v6.59.0Chrome Web Store
75Risk
Who publishes it

Mixmax - no other listings under this identity

What this publisher told the store about itself, and the other listings that told it the same thing.

Publisher
Mixmax, Inc.
Declared legal entity
Mixmax
Registered address
548 Market St PMB 60764, San Francisco, CA 94104-5401, US
Registered contact
Mixmax, Inc.

AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.

Publishers can request a review.

Findings

SeverityHIGH
ClassUNWANTED
TypeUnexpected
CWECWE-829
SourceAI SANDBOX

Mixmax loads remote page rules on Gmail pages

Mixmax requested selector/watcher JSON from extension.mixmax.com seconds after loading Gmail.

The same loader runs on Gmail, Salesforce, LinkedIn: it fetches two config files into page globals, then loads the module watching page elements.

01EvidenceCAUSE EFFECT
What actually happens
You did this

You open Gmail, Salesforce, or the LinkedIn sales widget while Mixmax is installed.

The content script maps the page to a build target such as gmail-inbox, salesforce, or linkedin.

The extension did this

The extension requests remote selector and watcher rules for that page.

On Gmail, dynamic analysis observed the watcher and selector JSON files load from extension.mixmax.com within seven seconds.

02EvidenceNETWORK CAPTURE
Captured request
GEThttps://extension.mixmax.com/config/gmail-inbox-watcher.json
200 OK, 10,736-byte JSON response observed during dynamic analysis.
03EvidenceNETWORK CAPTURE
Captured request
GEThttps://extension.mixmax.com/config/gmail-inbox-selectors.json
200 OK, 25,504-byte JSON response observed during dynamic analysis.
04EvidenceFIELD TABLE
Concrete configuration fields used by the Gmail page module
FieldValueWhy it matters
Page target
gmail-inboxTells the extension which web app you are using so it can load a matching rule set.
Compose body selector
COMPOSE_DIALOG_BODY = .Am.Al.editable.LW-avfPoints the extension at the editable email body area in your Gmail compose window.
Message watcher tag
tag = onceOpenMessage; sources = message, expandedMessageNames a page element state the extension should detect while you read messages.
Watch condition
attributeFilter = class; condition = has class h7Tells the extension which page changes should make a watched element count as active.
Finder interval
nativeComposeStandalone finder retries every 5000 msControls how often the extension retries looking for selected Gmail elements.
05EvidenceCODE COMPARE
The code that does this

Content script builds the Mixmax config URLs and stores both responses on window

What it actually does
Readable loader excerpt showing the same config fetches and dynamic importcore/content/app.js
const SFDC_DOMAIN = /(^|\.)(?:sales)?force\.com$/;
const hostname = window.location.hostname;
const isGmail = hostname === "mail.google.com";
const isLinkedIn = hostname === "www.linkedin.com" && window.location.pathname.startsWith("/sales/widget");
let buildTarget;
if (isGmail) {
    buildTarget = "gmail-inbox";
} else if (SFDC_DOMAIN.test(hostname)) {
    buildTarget = "salesforce";
} else if (isLinkedIn) {
    buildTarget = "linkedin";
}
const LOAD_FAILURE_WARN_ORIGINS = [ "https://mail.google.com" ];
function isValidWarningOrigin(origin) {
    if (!origin.startsWith("https://")) return false;
    if (LOAD_FAILURE_WARN_ORIGINS.includes(origin)) return true;
    return SFDC_DOMAIN.test(origin);
}
const warnEnvironments = new Set([ "staging" ]);
const rValidEnvironment = /^[0-9a-z-]+$/i;
function getExtensionOrigin(environment) {
    if (warnEnvironments.has(environment)) {
        console.warn(`Mixmax extension is running from ${environment}.`);
    }
    if (!environment || environment === "production") {
        return "https://extension.mixmax.com";
    }
    if (!rValidEnvironment.test(environment)) {
        throw new Error("extension origin not valid");
    }
    if (Environment.previewBranch) {
        return `https://extension-staging.mixmax.com/preview/${Environment.previewBranch}`;
    }
    return `https://extension-${environment}.mixmax.com`;
}
const GET_EVERYTHING = null;
chrome.storage.local.get(GET_EVERYTHING, (function(storage) {
    const environment = storage.environment;
    const remoteSource = getExtensionOrigin(environment) + "/src";
    const remoteConfig = getExtensionOrigin(environment) + "/config";
    const version = storage[`loadableCodeVersion_${environment}`] || "latest";
    const extensionBaseUrl = chrome.runtime.getURL(`loadable/${version}`);
    void loadLocal(extensionBaseUrl, remoteSource, remoteConfig);
}));
async function loadLocal(scriptBaseUrl, cssBaseUrl, configBaseUrl) {
    try {
        await Promise.all([ loadCSS(cssBaseUrl, 6), loadConfigs(configBaseUrl, 6) ]);
        if (isGmail) {
            await gmailPageInteropLoad;
        }
        const sourceSubdir = null;
        await loadScript(scriptBaseUrl, sourceSubdir);
    } catch (err) {
        if (isValidWarningOrigin(window.location.origin)) {
            alert("Failed to load extension locally. Check Chrome DevTools console for errors.");
        }
        throw err;
    }
}
async function loadScript(origin, sourceSubdir, numRetries = 0) {
    const maybeSourceSubdir = validateSourceSubdir(sourceSubdir) ? `${sourceSubdir}/` : "";
    const jsURL = new URL(`${origin}/${maybeSourceSubdir}build-${buildTarget}.js`);
    await retry((async num => {
        const url = new URL(jsURL);
        if (num) {
            url.searchParams.set("v", `mixmax-extension-${num}`);
        }
        try {
            await import(url.href);
        } catch (err) {
            if (err && err[Symbol.for("mixmaxErrorSource")] === "mixmax-extension-source") {
                err.isPermanent = true;
            }
            throw err;
        }
    }), numRetries + 1);
}
async function fetchJSON(url) {
    const res = await fetch(url, {
        mode: "cors",
        credentials: "omit",
        redirect: "follow",
        referrerPolicy: "origin"
    });
    if (!res.ok) {
        throw new Error(`failed to fetch JSON from ${url}`);
    }
    return await res.json();
}
async function loadConfigs(origin, numRetries = 0) {
    [window.SELECTORS, window.WATCHER_CONFIG] = await Promise.all([ retry((async () => await fetchJSON(`${origin}/${buildTarget}-selectors.json`)), numRetries + 1), retry((async () => await fetchJSON(`${origin}/${buildTarget}-watcher.json`)), numRetries + 1) ]);
}
06EvidenceCODE COMPARE
The code that does this

The Gmail module consumes the remote watcher object when it is present

What it actually does
Readable watcher replacement path in the Gmail moduleloadable/latest/build-gmail-inbox.js
if (window.WATCHER_CONFIG) {
  e.tags = {}, e.watchers = [], e.finders = {};
  for (const [t, n] of Object.entries(window.WATCHER_CONFIG)) {
    const r = Ts(n, t);
    Object.assign(e.tags, r.tags), e.watchers.push(...r.watchers), Object.assign(e.finders, r.finders)
  }
  window.WATCHER_CONFIG = {}
}
07EvidenceCODE COMPARE
The code that does this

Background service worker polls runtime configuration once per minute

What it actually does
Readable runtime configuration polling in the background workerbackground.js
async function checkRuntimeConfiguration() {
    if (!navigator.onLine)
        return;
    // Cache bust the gate URL just in case the browser decides to cache.
    const url = Environment.getExtensionUrl() + '/extension-runtime-config.json?cb=' + Date.now();
    try {
        const response = await fetch(url, { cache: 'no-cache' });
        if (!response.ok) {
            error_captureMessage('Non-200 response from S3', {
                culprit: 'Querying the extension runtime configuration',
                extra: {
                    status: response.status,
                    body: await response.text().catch((conversionError) => conversionError.message),
                },
            });
            return;
        }
        const runtimeConfiguration = await response.json();
        const runtimeConfigurationKey = `runtimeConfiguration_${Environment.get()}`;
        // TODO: use promise API as soon as we're on Mv3
        chrome.storage.local.get(runtimeConfigurationKey, (storage) => {
            // The API docs for `chrome.storage.local.get` say `storage` should never be null,
            // but we've found it sometimes (though rarely) is.
            if (!lodash_default().isEqual(runtimeConfiguration, storage === null || storage === void 0 ? void 0 : storage[runtimeConfigurationKey])) {
                void setStorage({ [runtimeConfigurationKey]: runtimeConfiguration });
            }
        });
    }
    catch (errorThrown) {
        errorThrown.sentryExtra = { fetchUrl: url };
        error_captureException(errorThrown, {
            culprit: 'Querying the extension runtime configuration',
        });
    }
}
chrome.alarms.onAlarm.addListener((alarm) => {
    if (alarm.name === ALARM_NAME) {
        void checkRuntimeConfiguration();
    }
});
void chrome.alarms.create(ALARM_NAME, { periodInMinutes: 1 });
SeverityMEDIUM
ClassUNWANTED
TypeUnexpected
CWECWE-829
SourceAI SANDBOX

Runtime configuration is fetched every minute

Mixmax requested its runtime config from extension.mixmax.com three times at 60s intervals, updating storage key runtimeConfiguration_production.

Source schedules the same check at startup and via a one-minute alarm, saving changed JSON.

01EvidenceCAUSE EFFECT
What actually happens
You did this

You keep the browser open while the extension is running.

The extension did this

The extension checks Mixmax's runtime-configuration endpoint every minute and stores changed JSON in browser storage.

02EvidenceTEMPORAL PATTERN
When this fires
Every 1 minute

The runtime-configuration check runs when the background script starts and then repeats once per minute.

03EvidenceNETWORK CAPTURE
Captured request
GEThttps://extension.mixmax.com/extension-runtime-config.json?cb=1776498047878
HTTP 200 observed on three requests at 60-second intervals; the fetched configuration updated runtimeConfiguration_production in browser storage.
04EvidenceFIELD TABLE
Fields and storage touched by the configuration fetch
FieldValueWhy it matters
Configuration host
extension.mixmax.comThis is the remote host your browser contacts for extension settings while Mixmax is running.
Configuration URL
https://extension.mixmax.com/extension-runtime-config.json?cb=1776498047878The full request identifies the runtime-configuration file and includes a changing cache-buster value.
Cache-buster value
1776498047878A changing number makes each check a fresh request instead of relying on a cached copy.
Stored configuration key
runtimeConfiguration_productionThe returned settings are saved locally so the extension can read them later.
05EvidenceCODE COMPARE
The code that does this

The runtime-configuration fetch, storage update, and one-minute alarm

What it actually does
Production extension URL resolverbackground.js
getExtensionUrl() {
    if (this.is(Environment.LOCAL))
        return 'https://extension-local.mixmax.com';
    else if (this.is(Environment.STAGING))
        return 'https://extension-staging.mixmax.com';
    else
        return 'https://extension.mixmax.com';
}
Runtime configuration request and storage writebackground.js
async function checkRuntimeConfiguration() {
    if (!navigator.onLine)
        return;
    // Cache bust the gate URL just in case the browser decides to cache.
    const url = Environment.getExtensionUrl() + '/extension-runtime-config.json?cb=' + Date.now();
    try {
        const response = await fetch(url, { cache: 'no-cache' });
        if (!response.ok) {
            error_captureMessage('Non-200 response from S3', {
                culprit: 'Querying the extension runtime configuration',
                extra: {
                    status: response.status,
                    body: await response.text().catch((conversionError) => conversionError.message),
                },
            });
            return;
        }
        const runtimeConfiguration = await response.json();
        const runtimeConfigurationKey = `runtimeConfiguration_${Environment.get()}`;
        // TODO: use promise API as soon as we're on Mv3
        chrome.storage.local.get(runtimeConfigurationKey, (storage) => {
            // The API docs for `chrome.storage.local.get` say `storage` should never be null,
            // but we've found it sometimes (though rarely) is.
            if (!lodash_default().isEqual(runtimeConfiguration, storage === null || storage === void 0 ? void 0 : storage[runtimeConfigurationKey])) {
                void setStorage({ [runtimeConfigurationKey]: runtimeConfiguration });
            }
        });
    }
    catch (errorThrown) {
        errorThrown.sentryExtra = { fetchUrl: url };
        error_captureException(errorThrown, {
            culprit: 'Querying the extension runtime configuration',
        });
    }
}
// Listen to `environment` changes to reload the configuration
chrome.storage.local.onChanged.addListener((changes) => {
    var _a;
    if ((_a = changes.environment) === null || _a === void 0 ? void 0 : _a.newValue) {
        // Give time for the Environment to be set
        setTimeout(checkRuntimeConfiguration, 0);
    }
});
// Check whether configuration has changed now (but give time for the Environment to be set)
//
// NOTE: this may prone to race conditions, but it won't affect production since it is the
// default environment. We can try to revise it when we move to promise-based code after Mv3.
setTimeout(checkRuntimeConfiguration, 0);
// Check back every minute.
const ALARM_NAME = 'runtimeConfiguration';
chrome.alarms.onAlarm.addListener((alarm) => {
    if (alarm.name === ALARM_NAME) {
        void checkRuntimeConfiguration();
    }
});
void chrome.alarms.create(ALARM_NAME, { periodInMinutes: 1 });
06EvidenceTHIRD PARTY LIST
Remote host contacted for runtime configuration
  • extension.mixmax.com

    Mixmax extension runtime-configuration host; receives the cache-busted GET and returns JSON that is stored locally.

SeverityMEDIUM
ClassUNWANTED
TypeUnexpected
CWECWE-200
SourceAI SANDBOX

Error reports send user context to Datadog

We observed Mixmax send Sentry error reports to Datadog after Gmail activated it.

Breadcrumbs included the Gmail address robertfinwitch@gmail.com in loginToken URLs, plus version/channel tags, service tags, stack traces, request history.

01EvidenceCAUSE EFFECT
What actually happens
You did this

You open Gmail while the Mixmax extension is active.

The extension did this

The extension posts Sentry error-reporting envelopes to Datadog that can include your account URL, extension metadata, stack traces, and request history.

02EvidenceNETWORK CAPTURE
Captured request
POSThttps://sentry-intake.datadoghq.com/api/1/envelope/?sentry_key=pub684b6e57211adb32dfdcf62550795cc0&sentry_version=7
Seventeen POST requests were observed. The recorded error-reporting evidence says one event contained robertfinwitch@gmail.com in breadcrumb URLs, extensionVersion and extensionChannel tags, service tags, full stack traces, and API request breadcrumb history.
03EvidenceFIELD TABLE
Observed fields and context in the error-reporting evidence
FieldValueWhy it matters
Gmail address in URL
https://app.mixmax.com/api/loginToken?user=robertfinwitch@gmail.comA breadcrumb URL can identify your account when the email address is embedded in the request path or query string.
Extension version and channel
extensionVersion=6.50.1, extensionChannel=mainThe report identifies which extension build and release channel produced the event.
Stack trace and culprit tag
culprit=Setting cookies for iframesThe report can include details about what code path failed and where the extension grouped the error.
API request breadcrumbs
https://app.mixmax.com/api/loginToken?user=robertfinwitch@gmail.comRequest history around the error can reveal what Mixmax API endpoints your browser contacted.
04EvidenceCODE COMPARE
The code that does this

Sentry setup, scope enrichment, and user email context

What it actually does
Background Sentry initializationbackground.js
function configureSentry(environment) {
    init({
        dsn: 'https://pub684b6e57211adb32dfdcf62550795cc0@sentry-intake.datadoghq.com/1',
        release: chrome.runtime.getManifest().version,
        environment,
        integrations: [new ExtraErrorData({ depth: 3 })],
        async beforeSend(event, { originalException }) {
            // If exception is present, check that it's the one that we want to log.
            // E.g. we don't want to log network errors in case user has no internet.
            if (originalException) {
                const shouldLogError = await getShouldLogError(originalException);
                if (!shouldLogError) {
                    return null;
                }
            }
            return event;
        },
    });
    setTags({
        extensionVersion: chrome.runtime.getManifest().version,
        extensionChannel: Environment.getExtensionChannel(),
        // We need to send service name as a tag to Datadog since error tracking group errors by the 'service' tag
        service: DD_ERROR_TRACKING_SERVICE,
    });
}
chrome.storage.local.onChanged.addListener((changes) => {
    var _a;
    if ((_a = changes.environment) === null || _a === void 0 ? void 0 : _a.newValue)
        configureSentry(changes.environment.newValue);
});
setTimeout(() => configureSentry(Environment.get()), 0);
Scope enrichment and capture wrapperbackground.js
function error_configureScope(scope, { user, tags, extra, fingerprint, culprit } = {}) {
    if (user)
        scope.setUser(user);
    if (tags)
        scope.setTags(tags);
    if (extra)
        scope.setExtras(extra);
    if (fingerprint)
        scope.setFingerprint(fingerprint);
    if (culprit) {
        scope.setTag('culprit', culprit);
        if (!fingerprint) {
            // See https://docs.sentry.io/platforms/node/data-management/event-grouping/sdk-fingerprinting/
            scope.setFingerprint(['{{ default }}', culprit]);
        }
    }
}
/**
 * Wrapper for Sentry's captureException.
 */
const error_captureException = function (error, options) {
    // Log to console locally and log to Sentry for production.
    if (!useRealClient()) {
        console.error(error.stack || error, options);
    }
    else {
        withScope((scope) => {
            error_configureScope(scope, options);
            captureException(error);
        });
    }
};
A background error path passes user email into the report scopebackground.js
ExtensionMessageBus.on('setCookiesForIFrames', async ({ user, origin }, _sender, sendResponse) => {
    try {
        const response = await fetch(`${Environment.getAppUrl()}/api/loginToken?user=${user}`);
        if (!response.ok) {
            error_captureMessage('Error getting login token', {
                culprit: 'Setting cookies for iframes',
                extra: { status: response.status },
            });
            if (sendResponse)
                sendResponse(false);
            return;
        }
        const loginToken = await response.json();
        if (!loginToken) {
            if (sendResponse)
                sendResponse(false);
            return;
        }
        chrome.cookies.set({
            domain: 'mixmax.com',
            expirationDate: Date.now() + 2 * 365 * 24 * 60 * 60,
            httpOnly: true,
            name: `mixmax_login_token_${user}_${Environment.get()}`,
            partitionKey: { topLevelSite: origin },
            path: '/',
            sameSite: 'no_restriction',
            secure: true,
            url: 'https://mixmax.com/',
            value: loginToken,
        }, () => {
            if (sendResponse)
                sendResponse(true);
        });
    }
    catch (err) {
        error_captureException(err, {
            user: { email: user },
            culprit: 'Setting cookies for iframes',
        });
        if (sendResponse)
            sendResponse(false);
    }
});
Content script Sentry initializationcore/content/app.js
function configureSentry(environment) {
    init({
        dsn: "https://pub684b6e57211adb32dfdcf62550795cc0@sentry-intake.datadoghq.com/1",
        release: chrome.runtime.getManifest().version,
        environment,
        integrations: [ new ExtraErrorData({
            depth: 3
        }) ],
        async beforeSend(event, {originalException}) {
            if (originalException) {
                const shouldLogError = await getShouldLogError(originalException);
                if (!shouldLogError) {
                    return null;
                }
            }
            return event;
        }
    });
    setTags({
        extensionVersion: chrome.runtime.getManifest().version,
        extensionChannel: Environment.getExtensionChannel(),
        service: DD_ERROR_TRACKING_SERVICE
    });
}
chrome.storage.local.onChanged.addListener((changes => {
    var _a;
    if ((_a = changes.environment) === null || _a === void 0 ? void 0 : _a.newValue) configureSentry(changes.environment.newValue);
}));
setTimeout((() => configureSentry(Environment.get())), 0);
05EvidenceTHIRD PARTY LIST
Remote host receiving error-reporting envelopes
  • sentry-intake.datadoghq.com

    Datadog Sentry-compatible intake endpoint that receives Mixmax extension session and error-reporting envelopes.

Updated 30 September 2026ocpljaamllnldhepankaeljmeeeghnid