Validate emails in Google Forms
A ready-made Apps Script, added to your form, checks the email address of each new response a few seconds after it arrives. It writes the result next to the response in the form's response sheet, can email you a daily digest of the bad ones, and has a menu item that checks every response already in the sheet. Your API key stays in your own settings of the script, never in the form or the sheet.
It cannot block a bad address at submit time. Google Forms gives a script no way to refuse an answer: a response is always saved first and checked a moment later. To catch typos before the form is sent, also turn on the question's own check in Forms: ⋮ → Response validation → Text → Email (or a regular expression of your own). That check only looks at the address's shape; the script then tells you whether the mailbox can receive mail.
Set it up
- Create an API key for the form on the API keys page, with a daily credit cap so the form can never spend more than you expect. To check more than 50 responses at once (see Check all responses), give the key the
bulkscope too. - In the form editor, open ⋮ → Apps Script. In a new project, replace everything in
Code.gswith the script below. If the project already holds code of yours, add a new script file instead (+ → Script) and paste the script there, so nothing of yours is replaced. Save. - Reload the form editor. Google adds the script's Email validation menu to it. Choose Email validation → Set API key and paste your key. The first time, Google asks you to allow the permissions the script needs.
- Choose Email validation → Choose the email question:
0for the address the form collects (Settings → Responses → Collect email addresses), or the number of the short-answer question that asks for it. When the form collects addresses, or has one short-answer question with "email" in its title, the script picks it by itself. - Choose Email validation → Check new responses: turn on.
To see results next to each response, link the form to a sheet (Responses → Link to Sheets). The script adds four columns at the right of the responses: Validation status, Validation reason, Validation action and Did you mean. Without a sheet, the daily digest is where you see what it found.
Try it for free first
Submit your form a few times with test addresses, such as accepted@sandbox.email-validation.org, no_mailbox@sandbox.email-validation.org or disposable@sandbox.email-validation.org. Each returns the result its name says, costs nothing and contacts no mail server, so you can watch the columns fill, and the digest arrive, before you spend a credit.
Each new response
- Checked once, by one trigger. Turning checks on adds one Apps Script trigger that runs on each new response, with the API key of the person who turned it on. A response without an address is not sent.
- Written to its row. The result goes to the response's row in the sheet. Forms writes that row a moment after the response is saved, so the script waits a few seconds for it.
- Never paid twice. When the same address already has a result in the sheet, from an earlier response in any mix of upper and lower case, the new row takes a copy of it instead of a new check. When a result finds no row to go to yet, the script keeps it (under a hash of the address, never the address) and the next run writes it without checking again.
- Retries only what is free to retry.
429and503answers, which are never charged, are sent again after theirRetry-Aftertime. Any other error is written aserrorwith its code, and Check all responses checks that row again.
Each completed check costs 1 credit, as with any validation request; unknown results, malformed and test addresses are free.
The daily digest
Off until you turn it on: Email validation → Daily digest of bad addresses: turn on. Once a day, around 8:00 in the script's time zone (Project Settings → Time zone in Apps Script), if any new response had an invalid or disposable address, or could not be checked (for example because the account ran out of credits), you get one mail. It lists the time, status and reason of up to 50 responses and counts the rest. A day without any sends nothing.
- It goes to you only: the Google account that turned it on, which is also the account that sends it. It never goes to a respondent or to any address in a response.
- It names no address. Find each response by its time in the response sheet or the form's Responses tab. The mail holds no respondent's address, so none is copied into anyone's mailbox.
- Only the person whose trigger checks new responses can turn it on. Turn it off with Email validation → Daily digest: turn off; turning checks off turns it off too.
Check all responses
Email validation → Check all responses checks every response in the sheet that has no status yet, for example the ones that arrived before you turned checks on. As in the Google Sheets template, rows that already have a status are skipped, an address in several rows is checked once, and a row marked error is checked again.
- Up to 50 addresses to check: single checks, 20 at a time and at most one batch a second, which stays within the per-second limit of an account that has not bought credits yet.
- More than 50: the script first asks whether to check them as one bulk list, naming the most credits it can use. It then uploads the list, starts it once it is ready, waits for it and writes each result to the rows that hold its address, as the sheet is when the results arrive.
- Resumes, never pays twice. Apps Script ends a run after a few minutes. When a list is still running then, the script remembers it (its id, never an address) and the next Check all responses collects it instead of sending a new one. A list and its results are kept 30 days after upload. Email validation → Cancel the waiting list cancels one that is still running; checks it had already done are charged, its unused credits are returned.
One run sends at most 100,000 addresses as a list. For a bigger sheet, download it and upload the file on the bulk page instead.
Several editors
Each person who uses the menu saves their own API key: it is kept in their own user properties of the script, which other editors of the form cannot read. Check all responses always runs on the key of the person who chooses it.
A form has one trigger for new responses. When another editor turns checks on, the script asks first; if they take over, new responses are checked with their key and credits, and the old trigger stops doing anything. Only the person whose trigger it is can turn it off. Email validation → Status shows whose key checks new responses, the email question, the digest and whether a list is waiting.
Permissions
The manifest asks Google for these permissions and no others:
| Permission | Why |
|---|---|
This form (forms.currentonly) | Read its questions and new responses, find its response sheet, add its submit trigger. |
Google Sheets (spreadsheets) | Open the form's own response sheet, by the id the form gives, to read the addresses and write the results. It opens no other spreadsheet. |
Connect to an external service (script.external_request) | Send the checks to https://api.email-validation.org (or https://api-eu.email-validation.org), and nowhere else. |
Run when you are not present (script.scriptapp) | The trigger for new responses and the daily digest's trigger. |
Send email as you (script.send_mail) | The daily digest, to your own address only. |
See your email address (userinfo.email) | The digest's only recipient: you. |
Show menus and messages (script.container.ui) | The Email validation menu in the form editor. |
Because this is your own script and not a published app, Google may say that it has not verified it. Choose Advanced and continue to your project to allow it.
Privacy
The script never logs, prints or shows an address, and its mail holds none: its messages name counts, columns, times and statuses only. Each address is sent only in the body of a POST request to https://api.email-validation.org (or https://api-eu.email-validation.org once you choose Email validation → Set region and type eu, for EU data residency), never in a URL, and the script connects to no other host. With the manifest installed, Apps Script itself enforces that.
Messages
| Message | What to do |
|---|---|
| Add your API key first | Choose Email validation → Set API key. |
| Choose the question that asks for the email address first | Choose Email validation → Choose the email question. |
| This form has no response sheet | Link one: Responses → Link to Sheets. |
| The response sheet has no column headed … | The email question's column was renamed: fix the header, or choose the question again. |
| Column … of the response sheet is in the way | Move that column: the four columns from Validation status on hold the results. |
| The API key was refused | The key was revoked, rolled or mistyped: set it again. |
| Your account is out of credits | Add credits, then run again. |
| The list is still being checked | Run Check all responses again in a few minutes. |
| Another editor of this form turned on checks | Say yes to check new responses with your own key instead. |
The script
Code.gs, the whole script:
/**
* Email validation for Google Forms (email-validation.org): checks the email address of each new response and writes
* the result next to it in the form's response sheet; can email you a daily digest of the bad ones; and checks every
* response already in the sheet from a menu.
* Guide: https://email-validation.org/docs/guides/google-forms/
*
* It cannot stop a bad address at submit time: Google Forms gives a script no way to refuse an answer, so a response
* is saved first and checked a moment later. To catch typos before submit, pair it with the question's own response
* validation (Short answer → ⋮ → Response validation → Text → Email).
*
* Setup: in the form editor open ⋮ → Apps Script. In a new project, replace the contents of Code.gs with this file; in
* a project that already holds code, add a new script file (+ → Script) and paste it there. Save, reload the form
* editor, then use the Email validation menu: Set API key, Choose the email question, Check new responses: turn on.
* The key is kept in your own user properties of this script (other editors of the form cannot read it), never in
* the form or the sheet.
*
* - New responses: an installable "on form submit" trigger, owned by the person who turned it on and running on their
* API key. One per form: another editor who turns it on takes it over, and the old trigger then does nothing.
* - The response sheet: when the form is linked to a sheet (Responses → Link to Sheets), the four columns Validation
* status, Validation reason, Validation action and Did you mean are filled next to the responses. Without a sheet,
* only the digest says what was found.
* - The digest: off until you turn it on. Once a day, if any new response had an invalid or disposable address or could
* not be checked, it mails you (the Google account that turned it on, never a respondent) the time, status and reason
* of each. It names responses by their time, not by their address.
* - Check all responses: up to 50 addresses to check go as single checks, 20 at a time; more go as one bulk list,
* after you confirm, which a later run collects when Apps Script's time runs out. Nothing is paid twice: rows that
* have a status are skipped, an address is checked once for every row that holds it, and a waiting list is collected,
* never sent again.
*
* Privacy: the script never logs, prints or shows an address, and its mail holds none; its messages name counts,
* columns and times only. An address is sent only in the body of a POST to the API, never in a URL.
*
* Every name starts with `ev` so the script can sit next to your own code. If your project already has an onOpen,
* delete the one below and call evAddMenu() from yours.
*/
const EV_TITLE = 'Email validation';
const EV_HOSTS = Object.freeze({ us: 'https://api.email-validation.org', eu: 'https://api-eu.email-validation.org' });
const EV_KEY_PROPERTY = 'EV_API_KEY';
const EV_REGION_PROPERTY = 'EV_REGION';
const EV_KEY_PATTERN = /^ev_live_[0-9A-Za-z]{79}$/;
// The header of the collected-email column in a response sheet ("Email Address"; also Email, E-mail).
const EV_EMAIL_HEADER = /^\s*e-?mail(\s+address)?\s*$/i;
const EV_RESULT_HEADERS = Object.freeze([
'Validation status',
'Validation reason',
'Validation action',
'Did you mean',
]);
const EV_DEFAULT_BATCH = 20;
const EV_MAX_RETRIES = 3;
const EV_MAX_RETRY_WAIT_S = 10;
// Apps Script ends a run after 6 minutes. No batch starts after 4, and one batch with every retry takes well under
// 2, so every answer received is written.
const EV_RUN_BUDGET_MS = 4 * 60 * 1000;
const EV_STATUS_ORDER = Object.freeze(['valid', 'invalid', 'catch_all', 'disposable', 'unknown', 'error']);
// More addresses to check than this go as one bulk list (config/integrations.ts GOOGLE_FORMS).
const EV_BULK_THRESHOLD = 50;
// Errors that every later request would get too: the run stops on them.
const EV_STOP_MESSAGES = Object.freeze({
unauthorized: 'The API key was refused (revoked, rolled or mistyped). Set it again: Email validation → Set API key.',
insufficient_credits: 'Your account is out of credits. Add credits in the dashboard, then run again.',
key_daily_cap: 'This API key reached its daily credit cap. Run again tomorrow, or raise the cap in the dashboard.',
forbidden: `The API refused this key: it needs the validate scope (and the bulk scope for more than ${EV_BULK_THRESHOLD} addresses) and an active account. If your account processes data in the EU only, choose Email validation → Set region and type eu.`,
forbidden_origin:
'This API key is limited to certain websites (allowed origins). Use a key without allowed origins for Google Forms.',
});
const EV_SLOW_DOWN = 'The API asked to slow down. Wait a minute, then run again.';
const EV_THROTTLED = 'The API paused checks for this account for a while (abuse_throttled). Run again later.';
const EV_SHEET_CHANGED =
'The response sheet changed during the run (rows or columns moved), so results were written only to rows that still hold their address. Run again to continue.';
const EV_NO_KEY = 'Add your API key first: Email validation → Set API key.';
const EV_NO_SOURCE =
'Choose the question that asks for the email address first: Email validation → Choose the email question.';
// Shared by every editor of the form (Document properties): which answer holds the address, and the one trigger
// that checks new responses. Neither is a secret; the API key never goes here.
const EV_SOURCE_PROPERTY = 'EV_SOURCE';
const EV_TRIGGER_PROPERTY = 'EV_SUBMIT_TRIGGER';
// The digest (user properties of the person who turned it on): on/off, its daily trigger and the waiting entries.
const EV_DIGEST_PROPERTY = 'EV_DIGEST';
const EV_DIGEST_TRIGGER_PROPERTY = 'EV_DIGEST_TRIGGER';
const EV_DIGEST_LOG_PROPERTY = 'EV_DIGEST_LOG';
const EV_DIGEST_STATUSES = Object.freeze(['invalid', 'disposable', 'error']);
const EV_DIGEST_MAX = 50;
const EV_DIGEST_HOUR = 8;
// A new response waits this long for the document lock (a menu run may hold it), and for its row to reach the
// response sheet (Forms writes it a moment after the trigger starts): 1 s, 2 s, then 4 s.
const EV_SUBMIT_LOCK_MS = 10000;
const EV_ROW_WAITS_MS = Object.freeze([1000, 2000, 4000]);
// A result that found no row to go to (the lock was busy, or the row came late) is kept, under a hash of the address,
// for the next run to write instead of checking (and paying for) the address again.
const EV_PARK_PREFIX = 'EV_PARK_';
const EV_PARK_DAYS = 30;
// The most addresses one JSON list may hold, and its largest body (the API's limits for POST /v1/bulk with JSON).
const EV_BULK_MAX_EMAILS = 100000;
const EV_BULK_MAX_BYTES = 4 * 1024 * 1024;
// A list is waited for until 3 minutes into the run, which leaves 3 to download and write its results.
const EV_BULK_POLL_BUDGET_MS = 3 * 60 * 1000;
const EV_BULK_FIRST_WAIT_MS = 2000;
const EV_BULK_MAX_WAIT_MS = 15000;
// Lists and their results are deleted this many days after upload; a list can be started within one day less.
const EV_BULK_RETENTION_DAYS = 30;
const EV_BULK_START_DAYS = 29;
const EV_BULK_MAX_READY_UNPAID = 2;
const EV_BULK_PENDING_PREFIX = 'EV_BULK_';
const EV_JOB_ID = /^job_[0-9A-Za-z]{22}$/;
// A list in one of these states has ended without results to write; `completed` has them; any other state (also one
// the API adds later) is still going on, so the list is kept.
const EV_BULK_ENDED = Object.freeze(['failed', 'cancelled', 'expired']);
function onOpen() {
evAddMenu();
}
/** Adds the Email validation menu to the form editor. */
function evAddMenu() {
FormApp.getUi()
.createMenu(EV_TITLE)
.addItem('Check all responses', 'evCheckAllResponses')
.addItem('Status', 'evShowStatus')
.addSeparator()
.addItem('Check new responses: turn on', 'evTurnOn')
.addItem('Check new responses: turn off', 'evTurnOff')
.addItem('Daily digest of bad addresses: turn on', 'evDigestOn')
.addItem('Daily digest: turn off', 'evDigestOff')
.addSeparator()
.addItem('Choose the email question', 'evChooseQuestion')
.addItem('Set API key', 'evSetApiKey')
.addItem('Set region (US or EU)', 'evSetRegion')
.addItem('Remove API key', 'evRemoveApiKey')
.addItem('Cancel the waiting list', 'evCancelPendingList')
.addToUi();
}
// ---- Menu items ----
function evCheckAllResponses() {
evAlert(evRunAll());
}
function evShowStatus() {
evAlert(evStatus());
}
function evSetApiKey() {
const ui = FormApp.getUi();
const answer = ui.prompt(
EV_TITLE,
'Paste your API key (it starts with ev_live_). It is kept in your own user properties of this script: other editors of the form cannot read it.',
ui.ButtonSet.OK_CANCEL,
);
if (answer.getSelectedButton() !== ui.Button.OK) return;
const key = answer.getResponseText().trim();
if (!EV_KEY_PATTERN.test(key)) {
evAlert(
'That is not an API key: keys start with ev_live_ and are 87 characters long. Copy it again from the dashboard.',
);
return;
}
PropertiesService.getUserProperties().setProperty(EV_KEY_PROPERTY, key);
evAlert('API key saved. Next: Email validation → Choose the email question, then Check new responses: turn on.');
}
function evRemoveApiKey() {
PropertiesService.getUserProperties().deleteProperty(EV_KEY_PROPERTY);
const mine = evSubmitTriggerIsMine();
evAlert(
mine
? 'API key removed. Checks of new responses are still on but do nothing without a key: turn them off (Email validation → Check new responses: turn off), or set a key again.'
: 'API key removed from your settings.',
);
}
function evSetRegion() {
const ui = FormApp.getUi();
const answer = ui.prompt(
EV_TITLE,
'Type us or eu. eu sends every request to the EU endpoint (EU data residency).',
ui.ButtonSet.OK_CANCEL,
);
if (answer.getSelectedButton() !== ui.Button.OK) return;
const region = answer.getResponseText().trim().toLowerCase();
if (!Object.prototype.hasOwnProperty.call(EV_HOSTS, region)) {
evAlert('Type us or eu.');
return;
}
PropertiesService.getUserProperties().setProperty(EV_REGION_PROPERTY, region);
evAlert(`Region saved: ${region}. A list already waiting stays on the region it was sent to.`);
}
/** Asks which answer holds the address: the email the form collects, or one of its short-answer questions. */
function evChooseQuestion() {
const ui = FormApp.getUi();
const form = FormApp.getActiveForm();
const items = form.getItems(FormApp.ItemType.TEXT);
const lines = [
`0: the email address the form collects (Settings → Responses → Collect email addresses; ${form.collectsEmail() ? 'on' : 'off'} in this form)`,
];
items.forEach((item, i) => {
lines.push(`${i + 1}: "${evTitle(item)}"`);
});
const answer = ui.prompt(
EV_TITLE,
`Which answer holds the respondent's email address? Type its number.\n${lines.join('\n')}`,
ui.ButtonSet.OK_CANCEL,
);
if (answer.getSelectedButton() !== ui.Button.OK) return;
const text = answer.getResponseText().trim();
const n = /^\d+$/.test(text) ? Number(text) : -1;
if (n < 0 || n > items.length) {
evAlert(`Type a number from 0 to ${items.length}.`);
return;
}
const docProps = PropertiesService.getDocumentProperties();
if (n === 0) {
docProps.setProperty(EV_SOURCE_PROPERTY, 'collected');
evAlert(
form.collectsEmail()
? 'Saved: the collected email address is checked.'
: 'Saved: the collected email address is checked. This form does not collect addresses yet: turn it on in Settings → Responses → Collect email addresses.',
);
return;
}
const item = items[n - 1];
docProps.setProperty(EV_SOURCE_PROPERTY, String(item.getId()));
evAlert(
`Saved: the answer to "${evTitle(item)}" is checked. In the response sheet, its column is the one headed with that question.`,
);
}
/** Turns on the trigger that checks each new response (one per form, on the key of the person who turns it on). */
function evTurnOn() {
const props = PropertiesService.getUserProperties();
if (!(props.getProperty(EV_KEY_PROPERTY) || '').trim()) {
evAlert(EV_NO_KEY);
return;
}
const form = FormApp.getActiveForm();
const docProps = PropertiesService.getDocumentProperties();
if (!evSource(form, docProps)) {
evAlert(EV_NO_SOURCE);
return;
}
const current = docProps.getProperty(EV_TRIGGER_PROPERTY);
if (current && evSubmitTriggerIsMine()) {
evAlert('Checks of new responses are already on, with your API key.');
return;
}
if (current) {
const ui = FormApp.getUi();
const answer = ui.alert(
EV_TITLE,
'Another editor of this form turned on checks of new responses, with their API key. Take over? New responses are then checked with your key and credits, and their trigger does nothing.',
ui.ButtonSet.YES_NO,
);
if (answer !== ui.Button.YES) {
evAlert('Nothing was changed.');
return;
}
}
evDeleteMyTriggers('evOnFormSubmit');
let trigger;
try {
trigger = ScriptApp.newTrigger('evOnFormSubmit').forForm(form).onFormSubmit().create();
} catch (_err) {
evAlert('Google did not create the trigger (each account may have only a few per script). Try again later.');
return;
}
docProps.setProperty(EV_TRIGGER_PROPERTY, trigger.getUniqueId());
const target = evTarget(form, evSource(form, docProps));
const where =
target === null
? 'This form has no response sheet, so results go only to the daily digest (Email validation → Daily digest of bad addresses: turn on). Link a sheet (Responses → Link to Sheets) to see them next to each response.'
: 'Each result is written to the four Validation columns of the response sheet a few seconds after the response arrives.';
evAlert(
`Checks of new responses are on, with your API key. ${where}\nA bad address cannot be refused at submit time: the response is saved first, then checked. Run Check all responses for the responses already there.`,
);
}
function evTurnOff() {
const docProps = PropertiesService.getDocumentProperties();
const mine = evSubmitTriggerIsMine();
const others = Boolean(docProps.getProperty(EV_TRIGGER_PROPERTY)) && !mine;
evDeleteMyTriggers('evOnFormSubmit');
evDigestStop(PropertiesService.getUserProperties());
if (others) {
evAlert(
'Checks of new responses are on with another editor’s API key: only they can turn them off (or choose Check new responses: turn on to take over with your key).',
);
return;
}
docProps.deleteProperty(EV_TRIGGER_PROPERTY);
evAlert(mine ? 'Checks of new responses are off. The daily digest is off too.' : 'Checks of new responses were off.');
}
/** Turns on the daily digest for the person whose trigger checks new responses. */
function evDigestOn() {
if (!evSubmitTriggerIsMine()) {
evAlert(
'Turn on checks of new responses first (Email validation → Check new responses: turn on): the digest lists what your checks found.',
);
return;
}
const props = PropertiesService.getUserProperties();
evDeleteMyTriggers('evSendDigest');
let trigger;
try {
trigger = ScriptApp.newTrigger('evSendDigest').timeBased().everyDays(1).atHour(EV_DIGEST_HOUR).create();
} catch (_err) {
evAlert('Google did not create the trigger (each account may have only a few per script). Try again later.');
return;
}
props.setProperty(EV_DIGEST_PROPERTY, 'on');
props.setProperty(EV_DIGEST_TRIGGER_PROPERTY, trigger.getUniqueId());
evAlert(
`The daily digest is on. Around ${EV_DIGEST_HOUR}:00 (the script's time zone, Project Settings), if any new response had an invalid or disposable address or could not be checked, you get one mail listing its time, status and reason. It goes only to your own Google account, never to a respondent, and names no address.`,
);
}
function evDigestOff() {
evDigestStop(PropertiesService.getUserProperties());
evAlert('The daily digest is off.');
}
function evDigestStop(props) {
evDeleteMyTriggers('evSendDigest');
props.deleteProperty(EV_DIGEST_PROPERTY);
props.deleteProperty(EV_DIGEST_TRIGGER_PROPERTY);
props.deleteProperty(EV_DIGEST_LOG_PROPERTY);
}
function evCancelPendingList() {
evAlert(evCancelPending());
}
// ---- Settings ----
function evRegion(props) {
const region = (props.getProperty(EV_REGION_PROPERTY) || 'us').trim().toLowerCase();
return Object.prototype.hasOwnProperty.call(EV_HOSTS, region) ? region : 'us';
}
function evTitle(item) {
return String(item.getTitle()).trim();
}
/**
* Which answer holds the address: { kind: 'collected' } or { kind: 'item', id, title }, or null. Nothing chosen: the
* collected email when the form collects it, else its one short-answer question titled like an email.
*/
function evSource(form, docProps) {
const chosen = (docProps.getProperty(EV_SOURCE_PROPERTY) || '').trim();
if (chosen === 'collected') return { kind: 'collected' };
const items = form.getItems(FormApp.ItemType.TEXT);
const asItem = (item) => ({ kind: 'item', id: String(item.getId()), title: evTitle(item) });
if (chosen) {
const item = items.find((i) => String(i.getId()) === chosen);
return item ? asItem(item) : null;
}
if (form.collectsEmail()) return { kind: 'collected' };
const likely = items.filter((i) => /e-?mail/i.test(evTitle(i)));
return likely.length === 1 ? asItem(likely[0]) : null;
}
/** The address a response gave, trimmed ('' when none). */
function evAnswer(response, source) {
let value = '';
if (source.kind === 'collected') {
value = response.getRespondentEmail();
} else {
const answer = response.getItemResponses().find((r) => String(r.getItem().getId()) === source.id);
value = answer ? answer.getResponse() : '';
}
return value === null || value === undefined ? '' : String(value).trim();
}
/**
* The form's response sheet: { spreadsheet, sheet, isEmailHeader, missing }, null when the form has no response
* sheet, or a message when it has one that cannot be used. The script opens no spreadsheet but this one.
*/
function evTarget(form, source) {
let id = '';
try {
if (form.getDestinationType() === FormApp.DestinationType.SPREADSHEET) id = form.getDestinationId();
} catch (_err) {
id = '';
}
if (!id) return null;
let spreadsheet;
try {
spreadsheet = SpreadsheetApp.openById(id);
} catch (_err) {
return 'The response sheet could not be opened (deleted, or you have no access to it). Link the form to a sheet again: Responses → Link to Sheets.';
}
const sheet = spreadsheet.getSheets().find((s) => evSameForm(s.getFormUrl(), form));
if (!sheet) {
return 'The response sheet has no tab that receives this form’s responses. Link the form to a sheet again: Responses → Link to Sheets.';
}
if (source.kind === 'collected') {
return {
spreadsheet: spreadsheet,
sheet: sheet,
isEmailHeader: (h) => EV_EMAIL_HEADER.test(h),
missing:
'The response sheet has no Email Address column: turn on Settings → Responses → Collect email addresses, or choose the question that asks for the address (Email validation → Choose the email question).',
};
}
return {
spreadsheet: spreadsheet,
sheet: sheet,
isEmailHeader: (h) => h === source.title,
missing: `The response sheet has no column headed "${source.title}" (the email question). Check the sheet's header row, or choose the question again (Email validation → Choose the email question).`,
};
}
/** Whether a sheet's form URL names this form (by its id, or its published or edit URL). */
function evSameForm(url, form) {
if (!url) return false;
const text = String(url);
return text.indexOf(form.getId()) >= 0 || text === form.getPublishedUrl() || text === form.getEditUrl();
}
/** Whether the trigger that checks new responses is one of the current user's own. */
function evSubmitTriggerIsMine() {
const uid = PropertiesService.getDocumentProperties().getProperty(EV_TRIGGER_PROPERTY);
return Boolean(uid) && ScriptApp.getProjectTriggers().some((t) => t.getUniqueId() === uid);
}
/** Deletes the current user's triggers for one handler (Apps Script shows a user only their own). */
function evDeleteMyTriggers(handler) {
for (const t of ScriptApp.getProjectTriggers()) {
if (t.getHandlerFunction() === handler) ScriptApp.deleteTrigger(t);
}
}
function evStatus() {
const props = PropertiesService.getUserProperties();
const docProps = PropertiesService.getDocumentProperties();
const form = FormApp.getActiveForm();
const source = evSource(form, docProps);
const lines = [
`API key: ${(props.getProperty(EV_KEY_PROPERTY) || '').trim() ? 'saved (yours)' : 'not set'}. Region: ${evRegion(props)}.`,
];
if (!source) lines.push('Email question: not chosen.');
else if (source.kind === 'collected') lines.push('Email question: the collected email address.');
else lines.push(`Email question: "${source.title}".`);
const mine = evSubmitTriggerIsMine();
if (!docProps.getProperty(EV_TRIGGER_PROPERTY)) lines.push('Checks of new responses: off.');
else lines.push(`Checks of new responses: on, ${mine ? 'with your API key' : 'with another editor’s API key'}.`);
lines.push(`Daily digest: ${mine && props.getProperty(EV_DIGEST_PROPERTY) === 'on' ? 'on' : 'off'}.`);
const target = source ? evTarget(form, source) : null;
if (target === null) lines.push('Response sheet: none linked (Responses → Link to Sheets).');
else if (typeof target === 'string') lines.push(`Response sheet: ${target}`);
else {
lines.push('Response sheet: linked.');
if (evPendingJob(props, target)) lines.push('A bulk list is waiting: run Check all responses to collect it.');
}
return lines.join('\n');
}
// ---- New responses ----
/**
* The installable "on form submit" trigger: checks the new response's address and writes the result to its row of
* the response sheet (and to the digest, when on). Only the trigger the form's settings name runs; any other (left
* over from an editor who was taken over) deletes itself when it is the current user's, and does nothing.
*/
function evOnFormSubmit(e) {
if (!e || !e.response) return;
const docProps = PropertiesService.getDocumentProperties();
const uid = String(e.triggerUid || '');
if (!uid || docProps.getProperty(EV_TRIGGER_PROPERTY) !== uid) {
for (const t of ScriptApp.getProjectTriggers()) if (t.getUniqueId() === uid) ScriptApp.deleteTrigger(t);
return;
}
const props = PropertiesService.getUserProperties();
const form = FormApp.getActiveForm();
const source = evSource(form, docProps);
if (!source) {
evNote(props, e.response, 'error', 'no_email_question');
return;
}
const address = evAnswer(e.response, source);
if (!evAddressKey(address)) return;
const key = (props.getProperty(EV_KEY_PROPERTY) || '').trim();
if (!key) {
evNote(props, e.response, 'error', 'no_api_key');
return;
}
const lock = LockService.getDocumentLock();
const locked = lock.tryLock(EV_SUBMIT_LOCK_MS);
try {
const target = evTarget(form, source);
const sheetTarget = target && typeof target === 'object' ? target : null;
const cells = evSubmitCells(sheetTarget, props, address, key, EV_HOSTS[evRegion(props)]);
const written = Boolean(locked && sheetTarget) && evSubmitWrite(sheetTarget, props, address, cells);
// a result with no row yet is kept for the next run (not an error: that is checked again anyway)
if (!written && target !== null && cells[0] !== 'error') evPark(props, evAddressKey(address), cells);
if (EV_DIGEST_STATUSES.indexOf(cells[0]) >= 0) evNote(props, e.response, cells[0], cells[1]);
} finally {
if (locked) lock.releaseLock();
}
}
/**
* The four result cells for a new response's address: a result the sheet (or a kept result) already has for the same
* address, else one single check (429 and 503, never charged, are sent again after Retry-After).
*/
function evSubmitCells(target, props, address, key, host) {
const addressKey = evAddressKey(address);
if (target) {
const plan = evPlanResponses(target, props);
if (typeof plan !== 'string' && plan.known.has(addressKey)) return plan.known.get(addressKey);
}
const parked = evParked(props).get(evAddressHash(addressKey));
if (parked) return parked.cells;
try {
return evFetchBatch([{ address: address, key: addressKey, rows: [] }], key, host)[0].cells;
} catch (_err) {
return ['error', 'network_error', '', ''];
}
}
/**
* Writes the result to the rows of the response sheet that hold the address and have no status yet, waiting a few
* seconds for the new response's row to arrive. Whether it wrote any.
*/
function evSubmitWrite(target, props, address, cells) {
const addressKey = evAddressKey(address);
for (let attempt = 0; ; attempt++) {
const plan = evPlanResponses(target, props);
if (typeof plan === 'string') return false;
const item = plan.items.concat(plan.copies).find((i) => i.key === addressKey);
if (item) {
evPrepareColumns(target.sheet, plan, false);
const current = evRowsStillInPlace(target.sheet, plan, [item]);
const out = item.rows.filter((row) => current[row]).map((row) => [row, cells]);
if (out.length) {
evWrite(target.sheet, plan.resultColumn, out);
return true;
}
}
if (attempt >= EV_ROW_WAITS_MS.length) return false;
Utilities.sleep(EV_ROW_WAITS_MS[attempt]);
}
}
// ---- Kept results (no row to go to yet) ----
/** A key for an address that does not hold it: a SHA-256 hash of its address key. */
function evAddressHash(addressKey) {
const digest = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, addressKey, Utilities.Charset.UTF_8);
return Utilities.base64EncodeWebSafe(digest).replace(/=+$/, '');
}
/** Keeps a result for the next run, without its "did you mean" (a suggested address: none is kept outside the sheet). */
function evPark(props, addressKey, cells) {
const kept = cells.slice(0, EV_RESULT_HEADERS.length - 1).concat(['']);
props.setProperty(EV_PARK_PREFIX + evAddressHash(addressKey), JSON.stringify({ c: kept, t: Date.now() }));
}
/** The kept results: Map of address hash → { property, cells }. Older than 30 days: deleted. */
function evParked(props) {
const out = new Map();
const oldest = Date.now() - EV_PARK_DAYS * 24 * 3600 * 1000;
for (const name of props.getKeys()) {
if (name.indexOf(EV_PARK_PREFIX) !== 0) continue;
let entry = null;
try {
entry = JSON.parse(props.getProperty(name));
} catch (_err) {
entry = null;
}
const ok =
entry &&
Array.isArray(entry.c) &&
entry.c.length === EV_RESULT_HEADERS.length &&
entry.c.every((c) => typeof c === 'string') &&
Number(entry.t) >= oldest;
if (ok) out.set(name.slice(EV_PARK_PREFIX.length), { property: name, cells: entry.c });
else props.deleteProperty(name);
}
return out;
}
// ---- The digest ----
/** Adds one response to the digest, when the digest is on: its time, status and reason (never its address). */
function evNote(props, response, status, reason) {
if (props.getProperty(EV_DIGEST_PROPERTY) !== 'on') return;
const lock = LockService.getUserLock();
if (!lock.tryLock(5000)) return;
try {
const log = evDigestLog(props);
let when = Date.now();
try {
when = response.getTimestamp().getTime();
} catch (_err) {
when = Date.now();
}
if (log.entries.length < EV_DIGEST_MAX) {
log.entries.push({ t: when, s: String(status), r: String(reason || '').slice(0, 64) });
} else {
log.more++;
}
props.setProperty(EV_DIGEST_LOG_PROPERTY, JSON.stringify(log));
} finally {
lock.releaseLock();
}
}
function evDigestLog(props) {
try {
const log = JSON.parse(props.getProperty(EV_DIGEST_LOG_PROPERTY) || '');
if (log && Array.isArray(log.entries) && Number.isFinite(log.more)) return log;
} catch (_err) {
// unreadable or none: a new log
}
return { entries: [], more: 0 };
}
/**
* The daily trigger: mails the waiting digest to the Google account that turned it on (Session's effective user, the
* trigger's owner), never to anyone else, then clears it. Nothing waiting: no mail.
*/
function evSendDigest(e) {
const props = PropertiesService.getUserProperties();
const uid = String((e && e.triggerUid) || '');
if (props.getProperty(EV_DIGEST_PROPERTY) !== 'on' || props.getProperty(EV_DIGEST_TRIGGER_PROPERTY) !== uid) {
for (const t of ScriptApp.getProjectTriggers()) if (t.getUniqueId() === uid) ScriptApp.deleteTrigger(t);
return;
}
const lock = LockService.getUserLock();
if (!lock.tryLock(30000)) return;
try {
const log = evDigestLog(props);
if (!log.entries.length && !log.more) {
// another editor took over the checks: nothing more can come, so this digest ends
if (!evSubmitTriggerIsMine()) evDigestStop(props);
return;
}
const to = Session.getEffectiveUser().getEmail();
if (!to || MailApp.getRemainingDailyQuota() < 1) return;
const form = FormApp.getActiveForm();
const count = log.entries.length + log.more;
MailApp.sendEmail({
to: to,
subject: `${count} ${count === 1 ? 'response' : 'responses'} to "${form.getTitle()}" need a look`,
body: evDigestBody(form, log),
name: EV_TITLE,
});
props.deleteProperty(EV_DIGEST_LOG_PROPERTY);
} finally {
lock.releaseLock();
}
}
function evDigestBody(form, log) {
const lines = [
`New responses to your form "${form.getTitle()}" whose email address is invalid or disposable, or could not be checked, since the last digest:`,
'',
];
for (const entry of log.entries) {
const when = `${new Date(entry.t).toISOString().slice(0, 16).replace('T', ' ')} UTC`;
lines.push(`- ${when}: ${entry.s}${entry.r ? ` (${entry.r})` : ''}`);
}
if (log.more) lines.push(`- and ${log.more} more`);
lines.push(
'',
`Find them by their time in the response sheet or the form's Responses tab: ${form.getEditUrl()}#responses`,
'What each status and reason means: https://email-validation.org/docs/statuses/',
'',
'You get this mail because you turned on the daily digest in the form’s Email validation menu. It goes only to you. To stop it: Email validation → Daily digest: turn off.',
);
return lines.join('\n');
}
// ---- Check all responses ----
/** Checks every response in the response sheet that has no status yet; returns the message for the user. */
function evRunAll() {
const props = PropertiesService.getUserProperties();
const key = (props.getProperty(EV_KEY_PROPERTY) || '').trim();
if (!key) return EV_NO_KEY;
const form = FormApp.getActiveForm();
const source = evSource(form, PropertiesService.getDocumentProperties());
if (!source) return EV_NO_SOURCE;
const target = evTarget(form, source);
if (target === null) {
return 'This form has no response sheet. Link one (Responses → Link to Sheets), then run again: Check all responses fills its Validation columns.';
}
if (typeof target === 'string') return target;
const lock = LockService.getDocumentLock();
if (!lock.tryLock(1000)) return 'A check is already going on in this form. Wait for it to finish.';
try {
const started = Date.now();
const pending = evPendingJob(props, target);
if (pending) return evBulkContinue(target, props, pending, key, started);
const plan = evPlanResponses(target, props);
if (typeof plan === 'string') return plan;
const nothing = evNothing(plan);
if (nothing) return nothing;
const region = evRegion(props);
if (plan.items.length <= EV_BULK_THRESHOLD)
return evCheck(target.sheet, plan, key, EV_HOSTS[region], EV_DEFAULT_BATCH);
return evBulkStart(target, props, plan, key, region, started);
} finally {
lock.releaseLock();
}
}
function evNothing(plan) {
if (plan.items.length || plan.copies.length) return '';
return plan.skipped
? 'Nothing to check: every response already has a status. Clear a status to check that address again.'
: 'Nothing to check: the response sheet holds no addresses yet.';
}
/**
* Finds the address column, the result columns and the addresses still to check in the response sheet (as the Google
* Sheets template's evPlan, for the whole sheet). Rows that already have a status are skipped; a row whose status is
* "error" is checked again. Each address is checked once: rows holding it in any mix of case share one check, and a
* row whose address already has a result (in another row, or kept from a new response) takes a copy of it instead of a
* new, charged check. `known` maps each address key that has a result to its cells. Returns a message instead when
* the sheet cannot take results.
*/
function evPlanResponses(target, props) {
const sheet = target.sheet;
const lastRow = sheet.getLastRow();
const lastColumn = sheet.getLastColumn();
if (lastRow < 1 || lastColumn < 1) return target.missing;
const headers = sheet
.getRange(1, 1, 1, lastColumn)
.getValues()[0]
.map((h) => String(h).trim());
const emailColumn = headers.findIndex(target.isEmailHeader) + 1;
if (!emailColumn) return target.missing;
const found = headers.indexOf(EV_RESULT_HEADERS[0]) + 1;
const resultColumn = found || lastColumn + 1;
const width = EV_RESULT_HEADERS.length;
const count = lastRow - 1;
let present = 0;
if (found) {
for (let i = 1; i < width; i++) {
const h = headers[found - 1 + i];
if (h && h !== EV_RESULT_HEADERS[i]) {
return `Column ${found + i} of the response sheet is in the way: the ${width} columns from "${EV_RESULT_HEADERS[0]}" on hold the results. Move that column, then run again.`;
}
}
if (emailColumn >= found && emailColumn < found + width) {
return 'The address column is inside the result columns of the response sheet: move it, then run again.';
}
// the result columns that exist yet (a range past the sheet's last column is an error)
present = Math.min(width, sheet.getMaxColumns() - found + 1);
const below = count ? sheet.getRange(2, found, count, present).getValues() : [];
for (let i = 1; i < present; i++) {
if (!headers[found - 1 + i] && below.some((r) => String(r[i]).trim() !== '')) {
return `Column ${found + i} of the response sheet is in the way: it has no header but holds data where the results go. Move that column, then run again.`;
}
}
}
const emails = count ? sheet.getRange(2, emailColumn, count, 1).getValues() : [];
const results = found && count ? sheet.getRange(2, resultColumn, count, present).getValues() : null;
const resultOf = (i) => (results ? String(results[i][0]).trim() : '');
const known = new Map();
for (let i = 0; i < emails.length; i++) {
const key = evAddressKey(emails[i][0]);
const status = resultOf(i);
if (key && status && status !== 'error' && !known.has(key)) {
const cells = [];
for (let c = 0; c < width; c++) cells.push(evCell(c < results[i].length ? results[i][c] : ''));
known.set(key, cells);
}
}
evUseParked(props, emails, resultOf, known);
const byKey = new Map();
const copies = new Map();
let skipped = 0;
for (let row = 2; row <= lastRow; row++) {
const address = String(emails[row - 2][0]).trim();
const key = evAddressKey(address);
if (!key) continue;
const status = resultOf(row - 2);
if (status && status !== 'error') {
skipped++;
continue;
}
const into = known.has(key) ? copies : byKey;
const item = into.get(key);
if (item) item.rows.push(row);
else into.set(key, { address: address, key: key, rows: [row], cells: known.get(key) });
}
return {
emailColumn: emailColumn,
emailHeader: headers[emailColumn - 1],
resultColumn: resultColumn,
items: Array.from(byKey.values()),
copies: Array.from(copies.values()),
skipped: skipped,
known: known,
};
}
/**
* Kept results: one whose address now has a result in the sheet is deleted (it was written); one whose address waits
* for a status is added to `known`, so its rows take it as a copy (no new check).
*/
function evUseParked(props, emails, resultOf, known) {
const parked = evParked(props);
if (!parked.size) return;
const seen = new Set();
for (let i = 0; i < emails.length; i++) {
const key = evAddressKey(emails[i][0]);
if (!key || seen.has(key)) continue;
seen.add(key);
const hit = parked.get(evAddressHash(key));
if (!hit) continue;
if (known.has(key)) props.deleteProperty(hit.property);
else if (!resultOf(i) || resultOf(i) === 'error') known.set(key, hit.cells.map(evCell));
}
}
// ---- Bulk lists (more than 50 addresses) ----
/** The user property that remembers the list a response sheet waits for (one per spreadsheet and tab). */
function evPendingProperty(target) {
return `${EV_BULK_PENDING_PREFIX}${target.spreadsheet.getId()}_${target.sheet.getSheetId()}`;
}
/** The list this response sheet waits for: { id, region }, or null. Holds no address. */
function evPendingJob(props, target) {
const text = props.getProperty(evPendingProperty(target));
if (!text) return null;
try {
const job = JSON.parse(text);
if (job && EV_JOB_ID.test(job.id) && Object.prototype.hasOwnProperty.call(EV_HOSTS, job.region)) return job;
} catch (_err) {
// unreadable: forgotten below
}
props.deleteProperty(evPendingProperty(target));
return null;
}
/** Asks before a list is sent: it may use as many credits as it holds addresses. */
function evConfirmList(count) {
const ui = FormApp.getUi();
const n = count.toLocaleString('en-US');
const answer = ui.alert(
EV_TITLE,
`Check ${n} addresses as one bulk list? Each address checked uses 1 credit (1 credit = 1 validated address), so the list uses at most ${n} credits. Unknown results, malformed addresses and test addresses on the sandbox domain are free.`,
ui.ButtonSet.YES_NO,
);
return answer === ui.Button.YES;
}
/** Sends the planned addresses as one list (after the user agreed), then waits for it like a later run would. */
function evBulkStart(target, props, plan, key, region, started) {
if (plan.items.length > EV_BULK_MAX_EMAILS) {
return `The response sheet holds ${plan.items.length.toLocaleString('en-US')} addresses to check; one run sends at most ${EV_BULK_MAX_EMAILS.toLocaleString('en-US')}. Upload the sheet as a file in the dashboard's bulk page instead.`;
}
const payload = JSON.stringify({ emails: plan.items.map((item) => item.address) });
if (Utilities.newBlob(payload).getBytes().length > EV_BULK_MAX_BYTES) {
return "These addresses are too large for one list. Upload the sheet as a file in the dashboard's bulk page instead.";
}
if (!evConfirmList(plan.items.length)) return 'Nothing was sent.';
// the confirmation may have stayed open while responses arrived: copies go only to rows still in place
const copied = evPrepareColumns(target.sheet, plan, true);
const answer = evApiRetrying('post', `${EV_HOSTS[region]}/v1/bulk?start=false`, key, payload, started);
if (answer.network) {
return 'The API could not be reached. If the list arrived, it waits unstarted in the dashboard (no credits used): cancel it there, then run again in a minute.';
}
const job = answer.body;
if (answer.http !== 202 || !job || !EV_JOB_ID.test(String(job.id))) {
return evBulkRefused(answer, 'The list could not be sent');
}
const pending = { id: job.id, region: region };
props.setProperty(evPendingProperty(target), JSON.stringify(pending));
return evBulkContinue(target, props, pending, key, started, copied);
}
/**
* Writes the result headers (adding columns when the sheet has no room) and, when asked, the results copied from
* other rows, to rows that still hold their address (as the template's evCheck does). Returns how many rows took a
* copy.
*/
function evPrepareColumns(sheet, plan, withCopies) {
const width = EV_RESULT_HEADERS.length;
const missing = plan.resultColumn + width - 1 - sheet.getMaxColumns();
if (missing > 0) sheet.insertColumnsAfter(sheet.getMaxColumns(), missing);
sheet.getRange(1, plan.resultColumn, 1, width).setValues([EV_RESULT_HEADERS.slice()]);
if (!withCopies || !plan.copies.length) return 0;
const current = evRowsStillInPlace(sheet, plan, plan.copies);
const cells = [];
for (const item of plan.copies) {
for (const row of item.rows) if (current[row]) cells.push([row, item.cells]);
}
evWrite(sheet, plan.resultColumn, cells);
return cells.length;
}
/**
* Waits for the list (starting it once it is ready), then writes its results. When Apps Script's time runs short
* the list keeps running and the next run collects it.
*/
function evBulkContinue(target, props, pending, key, started, copiedBefore) {
const host = EV_HOSTS[pending.region];
const forget = () => props.deleteProperty(evPendingProperty(target));
let wait = EV_BULK_FIRST_WAIT_MS;
let job = null;
for (;;) {
const answer = evApiRetrying('get', `${host}/v1/bulk/${pending.id}`, key, null, started);
if (answer.network)
return 'The API could not be reached. The list keeps running: run Check all responses again later.';
if (answer.code === 'not_found') {
forget();
return 'The list this form was waiting for was not found for this API key (was the key changed?). Run again to check the responses that are still empty.';
}
if (answer.http !== 200 || !answer.body) return evBulkRefused(answer, 'The list could not be read');
job = answer.body;
if (job.status === 'completed') break;
if (EV_BULK_ENDED.indexOf(job.status) >= 0) {
forget();
return evBulkEnded(job);
}
if (job.status === 'ready') {
const start = evApiRetrying('post', `${host}/v1/bulk/${pending.id}/start`, key, null, started);
if (start.network) return 'The API could not be reached. The list waits: run Check all responses again later.';
if (start.code === 'conflict') {
return `The list could not be started (conflict): a list can be started within ${EV_BULK_START_DAYS} days of upload, and this one is older or has changed state. Run Check all responses again: a list that ended is then forgotten with the reason.`;
}
if (start.http !== 200) {
const why = evBulkRefused(start, 'The list could not be started');
return `${why}\nThe list waits (it can be started within ${EV_BULK_START_DAYS} days of upload): run Check all responses again to start it.`;
}
job = start.body || job;
}
if (Date.now() - started + wait > EV_BULK_POLL_BUDGET_MS) {
const done = Number(job.processed) || 0;
const total = Number(job.unique_emails) || 0;
const progress = total ? ` (${done.toLocaleString('en-US')} of ${total.toLocaleString('en-US')} done)` : '';
return `The list is still being checked${progress}. Apps Script limits how long a run may take: run Check all responses again in a few minutes (within ${EV_BULK_RETENTION_DAYS} days) to write its results.`;
}
Utilities.sleep(wait);
wait = Math.min(EV_BULK_MAX_WAIT_MS, wait * 2);
}
const download = evApiRetrying(
'get',
`${host}/v1/bulk/${pending.id}/results?columns=email_only`,
key,
null,
started,
true,
);
if (download.network) {
return 'The API could not be reached. The list is done: run Check all responses again to write its results.';
}
if (download.http !== 200) return evBulkRefused(download, 'The results could not be downloaded');
const results = evResultsByKey(download.text);
if (!results) return 'The results could not be read. Run Check all responses again to download them again.';
const written = evBulkWrite(target, props, results, job, copiedBefore || 0);
// kept when the sheet cannot take the results yet (its layout changed): they are paid, and the next run writes them
if (written.done) forget();
return written.message;
}
/** Why a list ended without results to write (failed, cancelled, expired). */
function evBulkEnded(job) {
const code = job.error_code ? ` (${evCell(job.error_code)})` : '';
if (job.status === 'expired') {
return `The list expired: lists and their results are deleted ${EV_BULK_RETENTION_DAYS} days after upload, so its results can no longer be written. Run again to check the responses that are still empty.`;
}
if (job.status === 'cancelled') {
return 'The list was cancelled. Checks it had already done are charged and their results are not kept; its unused credits are returned. Run again to check the responses that are still empty.';
}
return `The list failed${code}: its unused credits are returned. Run again to check the responses that are still empty.`;
}
/**
* Writes a finished list's results to every row of the response sheet that still needs a status and holds an address
* of the list, as the sheet is now: rows may have moved or arrived since the list was sent, and every result of the
* list is paid for. { message, done }: done is false when the sheet cannot take the results yet, so the list is kept.
*/
function evBulkWrite(target, props, results, job, copiedBefore) {
const plan = evPlanResponses(target, props);
const credits = Number(job.credits_charged);
const used = Number.isFinite(credits)
? `The list used ${credits.toLocaleString('en-US')} ${credits === 1 ? 'credit' : 'credits'}.`
: '';
if (typeof plan === 'string') {
return {
message: `The list is done, but its results cannot be written yet: ${plan}\nThen run Check all responses to write them (no new charge; the results are kept ${EV_BULK_RETENTION_DAYS} days after upload).`,
done: false,
};
}
if (!plan.items.length && !plan.copies.length) {
return { message: [`The list is done. ${evNothing(plan)}`, used].filter(Boolean).join('\n'), done: true };
}
const copied = evPrepareColumns(target.sheet, plan, true) + copiedBefore;
const counts = {};
const cells = [];
let checked = 0;
let left = 0;
for (const item of plan.items) {
const answer = results.get(item.key);
if (!answer) {
left++;
continue;
}
checked++;
counts[answer[0]] = (counts[answer[0]] || 0) + 1;
for (const row of item.rows) cells.push([row, answer]);
}
evWrite(target.sheet, plan.resultColumn, cells);
// rows that took a copied result before the list was sent are counted as copied, not as skipped
const skipped = Math.max(0, plan.skipped - copiedBefore);
return {
message: [evSummary(counts, checked, left, skipped, copied, ''), used].filter(Boolean).join('\n'),
done: true,
};
}
/** The results CSV (columns=email_only) as a Map of address key → the four result cells; null when unreadable. */
function evResultsByKey(text) {
const rows = evParseCsv(text);
if (!rows || !rows.length) return null;
const header = rows[0];
const at = ['ev_status', 'ev_reason', 'ev_action', 'ev_did_you_mean'].map((h) => header.indexOf(h));
if (header[0] !== 'email' || at.some((i) => i < 0)) return null;
const out = new Map();
for (let r = 1; r < rows.length; r++) {
const row = rows[r];
if (row.length < header.length) continue;
// the API writes an address that starts like a formula with an apostrophe in front (CSV safety)
const key = evAddressKey(evUnescape(row[0]));
if (key && !out.has(key))
out.set(
key,
at.map((i) => evCell(evUnescape(row[i]))),
);
}
return out;
}
/** A CSV field without the CSV-safety apostrophe the API puts before a formula character. */
function evUnescape(value) {
return /^'[=+\-@\t\r=+-@]/.test(value) ? value.slice(1) : value;
}
/** RFC 4180 CSV (quoted fields, doubled quotes, CRLF or LF line ends) → rows of fields; null when malformed. */
function evParseCsv(text) {
const rows = [];
let row = [];
let field = '';
let quoted = false;
let i = 0;
const s = String(text || '');
while (i < s.length) {
const c = s[i];
if (quoted) {
if (c === '"') {
if (s[i + 1] === '"') {
field += '"';
i += 2;
continue;
}
quoted = false;
i++;
continue;
}
field += c;
i++;
continue;
}
if (c === '"' && field === '') {
quoted = true;
} else if (c === ',') {
row.push(field);
field = '';
} else if (c === '\n' || c === '\r') {
if (c === '\r' && s[i + 1] === '\n') i++;
row.push(field);
rows.push(row);
row = [];
field = '';
} else {
field += c;
}
i++;
}
if (quoted) return null;
if (field !== '' || row.length) {
row.push(field);
rows.push(row);
}
return rows;
}
/** The message for a list request the API refused. */
function evBulkRefused(answer, what) {
const code = answer.code;
if (code === 'forbidden') {
return `${what} (forbidden): lists need an API key with the bulk scope and an active account. If your account processes data in the EU only, choose Email validation → Set region and type eu (where lists are not yet available, check at most ${EV_BULK_THRESHOLD} addresses at a time).`;
}
if (Object.prototype.hasOwnProperty.call(EV_STOP_MESSAGES, code)) return EV_STOP_MESSAGES[code];
if (code === 'conflict') {
return `${what} (conflict). An account without a purchase may have at most ${EV_BULK_MAX_READY_UNPAID} lists waiting to start: start or cancel one in the dashboard, then run again.`;
}
if (code === 'payload_too_large') {
return `${what}: it is too large. Upload the sheet as a file in the dashboard's bulk page instead.`;
}
if (code === 'rate_limited' || code === 'service_unavailable') return EV_SLOW_DOWN;
if (code === 'abuse_throttled') return EV_THROTTLED;
return `${what} (${evCell(code)}). Run again in a few minutes.`;
}
/**
* Cancels the list this form's response sheet is waiting for and forgets it. A list that has already finished cannot
* be cancelled: it is kept, so the next run writes its results (no new charge).
*/
function evCancelPending() {
const props = PropertiesService.getUserProperties();
const form = FormApp.getActiveForm();
const source = evSource(form, PropertiesService.getDocumentProperties());
const target = source ? evTarget(form, source) : null;
if (!target || typeof target === 'string') return 'This form is not waiting for a list.';
const job = evPendingJob(props, target);
if (!job) return 'This form is not waiting for a list.';
const key = (props.getProperty(EV_KEY_PROPERTY) || '').trim();
if (!key) return EV_NO_KEY;
let answer;
try {
answer = evApi('delete', `${EV_HOSTS[job.region]}/v1/bulk/${job.id}`, key, null);
} catch (_err) {
return 'The API could not be reached. Try again in a minute.';
}
if (answer.http >= 200 && answer.http < 300) {
props.deleteProperty(evPendingProperty(target));
return 'The list was cancelled. Checks it had already done are charged and their results are not kept; its unused credits are returned. Responses it would have filled stay empty: the next run checks them again.';
}
if (answer.code === 'conflict') {
return 'The list has already finished and can no longer be cancelled: run Check all responses to write its results (no new charge), or to see why it ended without them.';
}
if (answer.code === 'not_found') {
props.deleteProperty(evPendingProperty(target));
return 'The list was not found for this API key (was the key changed?); this form no longer waits for it. Run Check all responses again to check the responses that are still empty.';
}
return `The list could not be cancelled (${evCell(answer.code)}). Try again in a minute.`;
}
/** evApi, with 429 and 503 answers (never charged) sent again after Retry-After, while the run has time. */
function evApiRetrying(method, url, key, payload, started, raw) {
let answer = null;
for (let attempt = 0; attempt <= EV_MAX_RETRIES; attempt++) {
try {
answer = evApi(method, url, key, payload, raw);
} catch (_err) {
return { network: true, http: 0, code: 'network_error', body: null, text: '' };
}
const retryable = (answer.http === 429 || answer.http === 503) && answer.code !== 'key_daily_cap';
if (!retryable || attempt === EV_MAX_RETRIES) break;
const wait = answer.retryAfter === null ? evBackoff(attempt) : answer.retryAfter;
if (wait > EV_MAX_RETRY_WAIT_S || Date.now() - started + wait * 1000 > EV_BULK_POLL_BUDGET_MS) break;
Utilities.sleep(Math.ceil(wait * 1000));
}
return answer;
}
/**
* One request to the API: { http, code, body, text, retryAfter }. `code` is the error code of an error answer ('' on
* success). `raw` keeps the body as text (the results CSV) instead of reading it as JSON.
*/
function evApi(method, url, key, payload, raw) {
const options = {
method: method,
headers: { Authorization: `Bearer ${key}` },
muteHttpExceptions: true,
followRedirects: false,
};
if (payload !== null && payload !== undefined) {
options.contentType = 'application/json';
options.payload = payload;
}
const response = UrlFetchApp.fetch(url, options);
const http = response.getResponseCode();
const text = response.getContentText();
let body = null;
if (!raw || http < 200 || http >= 300) {
try {
body = JSON.parse(text);
} catch (_err) {
body = null;
}
}
let code = '';
if (http < 200 || http >= 300) {
code = body && body.error && typeof body.error.code === 'string' ? body.error.code : 'unexpected_response';
}
return {
network: false,
http: http,
code: code,
body: raw ? null : body,
text: raw ? text : '',
retryAfter: http === 429 || http === 503 ? evRetryAfter(response.getHeaders()) : null,
};
}
function evAlert(text) {
const ui = FormApp.getUi();
ui.alert(EV_TITLE, text, ui.ButtonSet.OK);
}
// ---- Shared with the Google Sheets template (integrations/google-sheets/src/Code.js): keep equal (test/drift.test.ts) ----
/** The form under which two cells hold the same address, as the API compares them: trimmed, NFC, lowercased. */
function evAddressKey(value) {
return String(value).trim().normalize('NFC').toLowerCase();
}
/** Checks the planned addresses in batches and writes each batch's results as soon as they arrive. */
function evCheck(sheet, plan, key, host, batchSize) {
const started = Date.now();
const width = EV_RESULT_HEADERS.length;
const missing = plan.resultColumn + width - 1 - sheet.getMaxColumns();
if (missing > 0) sheet.insertColumnsAfter(sheet.getMaxColumns(), missing);
sheet.getRange(1, plan.resultColumn, 1, width).setValues([EV_RESULT_HEADERS.slice()]);
const counts = {};
let next = 0;
let stop = '';
let copied = 0;
if (plan.copies.length) {
// results already in the sheet for the same address: copied, never checked (and charged) again
const current = evRowsStillInPlace(sheet, plan, plan.copies);
const cells = [];
for (const item of plan.copies) {
for (const row of item.rows) {
if (current[row]) cells.push([row, item.cells]);
}
}
evWrite(sheet, plan.resultColumn, cells);
copied = cells.length;
if (current.changed) stop = EV_SHEET_CHANGED;
}
while (!stop && next < plan.items.length) {
if (Date.now() - started > EV_RUN_BUDGET_MS) {
stop = 'Apps Script limits how long a run may take, so this run stopped after 4 minutes.';
break;
}
const batch = plan.items.slice(next, next + batchSize);
const batchStarted = Date.now();
let answers;
try {
answers = evFetchBatch(batch, key, host);
} catch (_err) {
// No answer (network failure): the requests may have reached the API, so they are not sent again by
// themselves. Their rows stay empty and the next run checks them.
stop = 'The API could not be reached, so the last rows were left empty.';
break;
}
next += batch.length;
// A menu run does not lock the sheet: write only where the sheet still looks as it did when the run started.
const current = evRowsStillInPlace(sheet, plan, batch);
const cells = [];
batch.forEach((item, i) => {
const status = answers[i].cells[0];
counts[status] = (counts[status] || 0) + 1;
for (const row of item.rows) if (current[row]) cells.push([row, answers[i].cells]);
});
evWrite(sheet, plan.resultColumn, cells);
if (current.changed) {
stop = EV_SHEET_CHANGED;
break;
}
const stopped = answers.find((a) => a.stop);
if (stopped) {
stop = stopped.stop;
break;
}
if (answers.some((a) => a.retryable)) {
stop = answers.some((a) => a.code === 'abuse_throttled') ? EV_THROTTLED : EV_SLOW_DOWN;
break;
}
const wait = 1000 - (Date.now() - batchStarted);
if (next < plan.items.length && wait > 0) Utilities.sleep(wait);
}
return evSummary(counts, next, plan.items.length - next, plan.skipped, copied, stop);
}
/**
* Which rows of the batch still hold their address under the same headers: { <row>: true, …, changed }. When the
* header row moved, no row is in place.
*/
function evRowsStillInPlace(sheet, plan, batch) {
const out = { changed: false };
const headers = sheet.getRange(1, 1, 1, Math.max(plan.emailColumn, plan.resultColumn)).getValues()[0];
if (
String(headers[plan.emailColumn - 1]).trim() !== plan.emailHeader ||
headers[plan.resultColumn - 1] !== EV_RESULT_HEADERS[0]
) {
out.changed = true;
return out;
}
let first = Infinity;
let last = 0;
for (const item of batch) {
for (const row of item.rows) {
first = Math.min(first, row);
last = Math.max(last, row);
}
}
const values = sheet.getRange(first, plan.emailColumn, last - first + 1, 1).getValues();
for (const item of batch) {
for (const row of item.rows) {
if (evAddressKey(values[row - first][0]) === item.key) out[row] = true;
else out.changed = true;
}
}
return out;
}
/** Sends one batch in parallel; 429 and 503 answers (never charged) are sent again after Retry-After. */
function evFetchBatch(batch, key, host) {
const answers = new Array(batch.length);
let todo = batch.map((_item, i) => i);
for (let attempt = 0; todo.length; attempt++) {
let responses;
try {
responses = UrlFetchApp.fetchAll(todo.map((i) => evRequest(host, key, batch[i].address)));
} catch (err) {
// a failed first attempt is the caller's to report; on a retry, keep the answers already received (the rows
// being retried keep their 429 or 503 error and are checked again on the next run)
if (attempt === 0) throw err;
break;
}
const again = [];
let wait = 0;
responses.forEach((response, j) => {
const answer = evRead(response);
answers[todo[j]] = answer;
if (answer.retryable && attempt < EV_MAX_RETRIES) {
again.push(todo[j]);
wait = Math.max(wait, answer.retryAfter === null ? evBackoff(attempt) : answer.retryAfter);
}
});
// an answer that stops the run (a refused key, no credits) ends the batch: retrying the rest would only repeat it
const stops = todo.some((i) => answers[i].stop);
if (!again.length || stops || wait > EV_MAX_RETRY_WAIT_S) break;
Utilities.sleep(Math.ceil(wait * 1000));
todo = again;
}
return answers;
}
function evRequest(host, key, address) {
return {
url: `${host}/v1/validate`,
method: 'post',
contentType: 'application/json',
headers: { Authorization: `Bearer ${key}` },
payload: JSON.stringify({ email: address }),
muteHttpExceptions: true,
followRedirects: false,
};
}
/** One answer → the four result cells, and whether it may be retried or stops the run. */
function evRead(response) {
const http = response.getResponseCode();
let body = null;
try {
body = JSON.parse(response.getContentText());
} catch (_err) {
body = null;
}
if (http >= 200 && http < 300) {
if (body && typeof body.status === 'string') {
return {
cells: [body.status, body.reason, body.action, body.did_you_mean].map(evCell),
code: '',
retryable: false,
retryAfter: null,
stop: '',
};
}
return evError('unexpected_response', http, response);
}
const code = body && body.error && typeof body.error.code === 'string' ? body.error.code : 'unexpected_response';
return evError(code, http, response);
}
function evError(code, http, response) {
const retryable = (http === 429 || http === 503) && code !== 'key_daily_cap';
return {
cells: ['error', evCell(code), '', ''],
code: code,
retryable: retryable,
retryAfter: retryable ? evRetryAfter(response.getHeaders()) : null,
stop: Object.prototype.hasOwnProperty.call(EV_STOP_MESSAGES, code) ? EV_STOP_MESSAGES[code] : '',
};
}
/** Retry-After in whole seconds (digits or an HTTP date), or null. */
function evRetryAfter(headers) {
const name = Object.keys(headers || {}).find((h) => h.toLowerCase() === 'retry-after');
if (!name) return null;
const value = String(headers[name]).trim();
if (/^\d+$/.test(value)) return Number(value);
const date = Date.parse(value);
return Number.isNaN(date) ? null : Math.max(0, Math.ceil((date - Date.now()) / 1000));
}
/** Seconds to wait before attempt + 1 when there is no Retry-After: 0.25–0.5 s, then 0.5–1 s, 1–2 s. */
function evBackoff(attempt) {
const base = Math.min(8, 0.5 * 2 ** attempt);
return base / 2 + (Math.random() * base) / 2;
}
/** A value as cell text that Sheets never reads as a formula. */
function evCell(value) {
const text = value === null || value === undefined ? '' : String(value);
return /^[=+\-@]/.test(text) ? `'${text}` : text;
}
/** Writes [row, cells] pairs, one call per run of consecutive rows. */
function evWrite(sheet, column, cells) {
cells.sort((a, b) => a[0] - b[0]);
let start = 0;
for (let i = 1; i <= cells.length; i++) {
if (i < cells.length && cells[i][0] === cells[i - 1][0] + 1) continue;
sheet
.getRange(cells[start][0], column, i - start, EV_RESULT_HEADERS.length)
.setValues(cells.slice(start, i).map((c) => c[1]));
start = i;
}
}
function evSummary(counts, checked, left, skipped, copied, stop) {
const known = EV_STATUS_ORDER.filter((s) => counts[s]);
const other = Object.keys(counts).filter((s) => EV_STATUS_ORDER.indexOf(s) < 0);
const parts = known.concat(other).map((s) => `${s} ${counts[s]}`);
const lines = [
`Checked ${checked} ${checked === 1 ? 'address' : 'addresses'}${parts.length ? ` (${parts.join(', ')})` : ''}.`,
];
if (counts.error) lines.push('Rows marked error are checked again on the next run.');
if (skipped)
lines.push(
`${skipped} ${skipped === 1 ? 'row' : 'rows'} already had a status and ${skipped === 1 ? 'was' : 'were'} skipped.`,
);
if (copied)
lines.push(
`${copied} ${copied === 1 ? 'row' : 'rows'} took the result another row already had for the same address (no new check).`,
);
if (stop) lines.push(stop);
if (left) lines.push(`${left} ${left === 1 ? 'address is' : 'addresses are'} left: run again to continue.`);
return lines.join('\n');
}If your project already has an onOpen function, delete the script's own onOpen and call evAddMenu() from yours. Every name in the script starts with ev, so it does not clash with your own code.
The manifest
The manifest, appsscript.json, pins the permissions above and lets the script reach only the two API hosts. Turn on Project Settings → Show "appsscript.json" manifest file in editor to see it.
- In a project that holds only this script, replace the file's contents with the manifest below.
- In a project with code of yours, do not replace the file: add the
oauthScopesandurlFetchWhitelistentries to it instead, with any your own code needs. Once a project's manifest has aurlFetchWhitelist, Apps Script blocks every host that is not on it, for your code too.
{
"timeZone": "Etc/UTC",
"runtimeVersion": "V8",
"exceptionLogging": "NONE",
"oauthScopes": [
"https://www.googleapis.com/auth/forms.currentonly",
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/script.external_request",
"https://www.googleapis.com/auth/script.scriptapp",
"https://www.googleapis.com/auth/script.send_mail",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/script.container.ui"
],
"urlFetchWhitelist": ["https://api.email-validation.org/", "https://api-eu.email-validation.org/"]
}Related
- Signup form validation: check addresses before a form is sent, on your own site
- Google Sheets template for a sheet of addresses
- Statuses and reasons: what each value in the result columns means
- Errors, rate limits and test addresses