Prepare Gmail work before sending
Use GmailApp in Google Apps Script to search test threads, create plain-text or HTML drafts with createDraft(), and track per-message delivery state.
Build a bounded draft workflow and reconcile its per-message state before repeating work or deliberately sending.
Select labeled Gmail messages, create review drafts addressed to your own mailbox with a generated text attachment, and record per-message checkpoints in User Properties so subsequent runs skip messages that were already processed or left in an uncertain state.
This chapter builds on Understand the runtime and permissions and the distinction between batched calculations and per-item external checkpoints in Read and write Sheets in batches.
Choose the service and the permission boundary
GmailApp searches conversations, reads messages, creates drafts, modifies labels, and sends mail, whereas MailApp only sends email under the narrower https://www.googleapis.com/auth/script.send_mail scope (see the MailApp reference). When a script only sends email to known recipients without reading the inbox or creating drafts, use MailApp.
Because this project searches labeled messages and creates drafts, it combines two Gmail interfaces:
Gmail(the advanced Gmail service) lists individual message IDs and fetches only theSubjectheader via metadata format.GmailApp(the built-in service) verifies that the source label exists and builds the MIME draft withcreateDraft.
The manifest declares https://mail.google.com/, which authorizes both the GmailApp calls and the Gmail API calls used here (see the authorization guide).
Select messages, not an assumed conversation size
In Gmail, a message is a single email and a thread is a conversation containing one or more messages.
GmailApp.search(query, start, max) paginates by thread: GmailApp.search('label:BookGmailPractice', 0, 3) returns up to three threads, and calling thread.getMessages() loads every message in that thread regardless of how many replies it contains.
To bound the number of individual messages instead of threads, this project calls Gmail.Users.Messages.list('me', { ... }) with maxResults between 1 and 3. The response lists message IDs and thread IDs directly, and the script logs moreMatches: Boolean(response.nextPageToken) when additional messages match the query.
Because a thread's ID can change depending on its contents, the ledger keys each checkpoint on the source message ID (sourceMessageId) and records the created draft ID (draftId) separately, logging threadId only as diagnostic context. The ledger caps tracked messages at five entries across the project's lifetime.
Use a label to select messages
A Gmail label is a tag applied to individual messages rather than a folder. The script verifies that the label BookGmailPractice exists in the mailbox and queries messages with:
label:BookGmailPractice -in:drafts -in:spam -in:trash -subject:BookGmailReview The leading - operators exclude drafts, spam, trash, and previously generated BookGmailReview subjects.
Test the query in the Gmail search bar first, noting that the Gmail web UI performs thread-wide matching and alias expansion that the REST API does not (see the Gmail filtering guide). Also note that applying a label to a conversation in the UI tags the messages currently in that thread, while later replies do not inherit the label automatically and drafts cannot hold custom labels (see Manage labels).
Prepare the isolated project
In a test Gmail account you own, create the label
BookGmailPracticeand apply it to two non-draft test messages.Under that same account, open
script.google.comand create a New project namedGmail review practice.Replace
Code.gswith the complete project file below. In Project Settings, enable Show "appsscript.json" manifest file in editor, replaceappsscript.jsonwith the project manifest, and save both files.The manifest's
enabledAdvancedServicesentry binds the Gmail APIv1to theGmailglobal symbol. Confirm that Gmail API appears under Services in the left sidebar. If your script is linked to a standard Google Cloud project rather than the default Apps Script Cloud project, also enable the Gmail API in that Cloud project's console (see enable advanced services).At the top of
Code.gs, setrecipientto your test mailbox's primary email address (plain address only, without a display name). KeepallowDraftCreation: false,maxMessages: 3, and the three resolution fields set to''.
Before listing messages, the script validates recipient and maxMessages (1 to 3) and verifies that recipient matches the authenticated mailbox's address returned by Gmail.Users.getProfile('me').
// This file combines two Gmail surfaces:
// Manual, single-operator exercise. There is no send function.
const GMAIL_WORKFLOW = {
recipient: '', // Your test mailbox's primary address, not an alias or a list.
labelName: 'BookGmailPractice',
maxMessages: 3, // Allowed range: 1 through 3. Only one page is requested.
allowDraftCreation: false,
resolutionMessageId: '', // Copy sourceMessageId from inspectGmailWork().
resolution: '', // KEPT_DRAFT, SENT_CONFIRMED, DISCARDED, or ABANDONED_UNCERTAIN.
resolutionConfirmation: '' // CLOSE <sourceMessageId> AS <resolution>
};
/**
* @typedef {{
* sourceMessageId: string,
* draftId: string,
* marker: string,
* state: string,
* resolution: string,
* updatedAt: string
* }} GmailLedgerItem
*/
const GMAIL_WORKFLOW_LEDGER = 'gmail-workflows.v1';
const GMAIL_WORKFLOW_MAX_ITEMS = 5;
/** Read-only discovery: no draft, label, message, or property writes. */
function previewGmailWork() {
const recipient = requireGmailConfiguration_();
requireOwnMailbox_(recipient);
const ledger = readGmailLedger_();
const page = discoverGmailMessages_();
for (const message of page.messages) {
const item = ledger.find((entry) => entry.sourceMessageId === message.id);
Logger.log(JSON.stringify({
sourceMessageId: message.id,
threadId: message.threadId,
state: item ? item.state : 'NEW'
}));
}
Logger.log(JSON.stringify({
selectedMessages: page.messages.length,
moreMatches: page.moreMatches,
trackedItems: ledger.length,
draftsCreated: 0
}));
}
/** Creates at most three drafts addressed only to the configured own mailbox. */
function createGmailReviewDrafts() {
const recipient = requireGmailConfiguration_();
if (GMAIL_WORKFLOW.allowDraftCreation !== true) {
throw new Error('Draft creation is off. Preview first, then set allowDraftCreation to true.');
}
withGmailWorkflowLock_(() => {
requireOwnMailbox_(recipient);
const ledger = readGmailLedger_();
if (ledger.some((item) => item.state === 'CREATE_UNCERTAIN')) {
throw new Error('An earlier draft attempt is uncertain. Inspect and reconcile it before more creation.');
}
const page = discoverGmailMessages_();
const pending = page.messages.filter((message) =>
!ledger.some((item) => item.sourceMessageId === message.id)
);
if (ledger.length + pending.length > GMAIL_WORKFLOW_MAX_ITEMS) {
throw new Error('This exercise tracks at most five messages. Keep its markers; do not reset to retry.');
}
let created = 0;
for (const message of pending) {
const marker = 'BookGmailReview-' + Utilities.getUuid();
const item = {
sourceMessageId: message.id,
draftId: '',
marker: marker,
state: 'CREATE_UNCERTAIN',
resolution: '',
updatedAt: new Date().toISOString()
};
const subject = '[' + marker + '] Review selected message';
const body = [
'Private review note. This is not a reply to the original sender.',
'',
'Selected subject: ' + message.subject,
'Source message ID: ' + message.id,
'Review marker: ' + marker,
'',
'Decision: review later. No source message body or attachment was copied.'
].join('\n');
const attachment = Utilities.newBlob(
'Review marker: ' + marker + '\nDecision: review later.\n',
'text/plain',
'review-note.txt'
);
// Save intent BEFORE dispatch. A later run must not retry an unclear result.
ledger.push(item);
writeGmailLedger_(ledger);
try {
const draft = GmailApp.createDraft(recipient, subject, body, {
attachments: [attachment]
});
item.draftId = draft.getId();
item.state = 'DRAFT_READY';
item.updatedAt = new Date().toISOString();
// Checkpoint this item now, not after all draft creations.
writeGmailLedger_(ledger);
} catch (error) {
// Do not reset the marker or retry. The remote action may have succeeded.
Logger.log(JSON.stringify({ sourceMessageId: message.id, marker: marker }));
throw new Error('Draft creation or its checkpoint failed. Inspect this marker in Gmail before continuing.');
}
created += 1;
Logger.log(JSON.stringify(item));
}
Logger.log(JSON.stringify({
draftsCreated: created,
alreadyTracked: page.messages.length - pending.length,
moreMatches: page.moreMatches
}));
});
}
/** Reads saved observations only. It does not query Gmail or verify a draft exists. */
function inspectGmailWork() {
const ledger = readGmailLedger_();
for (const item of ledger) Logger.log(JSON.stringify(item));
Logger.log('Tracked items: ' + ledger.length);
}
/** Records the operator's review, without sending, deleting, or querying mail. */
function recordGmailResolution() {
const id = GMAIL_WORKFLOW.resolutionMessageId;
const resolution = GMAIL_WORKFLOW.resolution;
const outcomes = ['KEPT_DRAFT', 'SENT_CONFIRMED', 'DISCARDED', 'ABANDONED_UNCERTAIN'];
if (typeof id !== 'string' || !id.trim() || id.length > 200 || !outcomes.includes(resolution)) {
throw new Error('Set resolutionMessageId and one documented resolution after reviewing this item in Gmail.');
}
if (GMAIL_WORKFLOW.resolutionConfirmation !== 'CLOSE ' + id + ' AS ' + resolution) {
throw new Error('Set resolutionConfirmation to CLOSE <sourceMessageId> AS <resolution>.');
}
withGmailWorkflowLock_(() => {
const ledger = readGmailLedger_();
const item = ledger.find((entry) => entry.sourceMessageId === id);
if (!item) throw new Error('No tracked item has that sourceMessageId.');
if (item.state === 'CLOSED' && item.resolution === resolution) {
Logger.log('This resolution is already recorded. No write was needed.');
return;
}
item.state = 'CLOSED';
item.resolution = resolution;
item.updatedAt = new Date().toISOString();
writeGmailLedger_(ledger);
Logger.log(JSON.stringify(item));
});
}
function requireGmailConfiguration_() {
const recipient = GMAIL_WORKFLOW.recipient.trim().toLowerCase();
if (!/^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(recipient) ||
recipient.endsWith('@example.com')) {
throw new Error('Set recipient to your own test mailbox primary address, with no display name or recipient list.');
}
if (!/^[A-Za-z][A-Za-z0-9_-]{0,59}$/.test(GMAIL_WORKFLOW.labelName)) {
throw new Error('Use a label name of 1-60 letters, digits, underscores, or hyphens, starting with a letter.');
}
if (!Number.isInteger(GMAIL_WORKFLOW.maxMessages) ||
GMAIL_WORKFLOW.maxMessages < 1 || GMAIL_WORKFLOW.maxMessages > 3) {
throw new Error('maxMessages must be an integer from 1 through 3.');
}
return recipient;
}
/**
* @param {string} recipient
* @returns {void}
*/
function requireOwnMailbox_(recipient) {
const profile = Gmail.Users.getProfile('me');
if (!profile.emailAddress || profile.emailAddress.toLowerCase() !== recipient) {
throw new Error('recipient does not match the executing Gmail mailbox primary address. Stop and check the signed-in account.');
}
}
function discoverGmailMessages_() {
if (!GmailApp.getUserLabelByName(GMAIL_WORKFLOW.labelName)) {
throw new Error('Create the practice label in Gmail and apply it only to approved test messages.');
}
const query = 'label:' + GMAIL_WORKFLOW.labelName +
' -in:drafts -in:spam -in:trash -subject:BookGmailReview';
const page = Gmail.Users.Messages.list('me', {
q: query,
maxResults: GMAIL_WORKFLOW.maxMessages,
includeSpamTrash: false,
fields: 'messages(id,threadId),nextPageToken'
});
const references = page.messages || [];
if (references.length > GMAIL_WORKFLOW.maxMessages) {
throw new Error('Message listing exceeded the requested bound. No drafts were created.');
}
const messages = [];
for (const reference of references) {
if (!reference.id) throw new Error('A listed message has no usable ID.');
const message = Gmail.Users.Messages.get('me', reference.id, {
format: 'metadata',
metadataHeaders: ['Subject'],
fields: 'id,threadId,labelIds,payload/headers'
});
// The message may have moved since listing. Never turn a draft into an input.
const labels = message.labelIds || [];
if (labels.some((label) => ['DRAFT', 'SPAM', 'TRASH'].includes(label))) continue;
if (!message.id || !message.threadId) throw new Error('A listed message has no usable identity.');
const headers = message.payload && message.payload.headers ? message.payload.headers : [];
const subjectHeader = headers.find((header) => header.name && header.name.toLowerCase() === 'subject');
const subject = subjectHeader && subjectHeader.value ? subjectHeader.value : '(no subject)';
messages.push({
id: message.id,
threadId: message.threadId,
subject: subject.replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, 160)
});
}
return { messages: messages, moreMatches: Boolean(page.nextPageToken) };
}
/**
* @returns {GmailLedgerItem[]}
*/
function readGmailLedger_() {
const raw = PropertiesService.getUserProperties().getProperty(GMAIL_WORKFLOW_LEDGER);
if (raw === null) return [];
let ledger;
try {
ledger = JSON.parse(raw);
} catch (error) {
throw new Error('The saved ledger is not valid JSON. Preserve it and reconcile; do not clear it to retry.');
}
if (!Array.isArray(ledger) || ledger.length > GMAIL_WORKFLOW_MAX_ITEMS) {
throw new Error('The saved ledger has an unexpected shape. Preserve it and reconcile.');
}
const ids = new Set();
for (const item of ledger) {
if (!item || typeof item.sourceMessageId !== 'string' ||
!item.sourceMessageId.trim() || item.sourceMessageId.length > 200 || ids.has(item.sourceMessageId) ||
typeof item.draftId !== 'string' || item.draftId.length > 200 ||
typeof item.marker !== 'string' || !/^BookGmailReview-[a-f0-9-]{36}$/i.test(item.marker) ||
!['CREATE_UNCERTAIN', 'DRAFT_READY', 'CLOSED'].includes(item.state) ||
!['', 'KEPT_DRAFT', 'SENT_CONFIRMED', 'DISCARDED', 'ABANDONED_UNCERTAIN'].includes(item.resolution) ||
typeof item.updatedAt !== 'string' || !Number.isFinite(Date.parse(item.updatedAt)) ||
(item.state === 'DRAFT_READY' && !item.draftId) ||
(item.state === 'CLOSED' && !item.resolution)) {
throw new Error('A saved ledger item is invalid. Preserve it and reconcile; do not reset its marker.');
}
ids.add(item.sourceMessageId);
}
return ledger;
}
/**
* @param {GmailLedgerItem[]} ledger
* @returns {void}
*/
function writeGmailLedger_(ledger) {
const text = JSON.stringify(ledger);
if (ledger.length > GMAIL_WORKFLOW_MAX_ITEMS || text.length > 8000) {
throw new Error('The exercise ledger is full. No automatic marker deletion is allowed.');
}
PropertiesService.getUserProperties().setProperty(GMAIL_WORKFLOW_LEDGER, text);
}
/**
* @param {function(): void} operation
* @returns {void}
*/
function withGmailWorkflowLock_(operation) {
const lock = LockService.getUserLock();
if (!lock.tryLock(1000)) throw new Error('Another run is using this ledger. Wait for it to finish.');
try {
operation();
} finally {
lock.releaseLock();
}
}
{
"timeZone": "Etc/UTC",
"dependencies": {
"enabledAdvancedServices": [
{
"userSymbol": "Gmail",
"version": "v1",
"serviceId": "gmail"
}
]
},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"oauthScopes": [
"https://mail.google.com/"
]
}
Preview without writing mail
Select previewGmailWork and click Run, authorizing the project when prompted. With two labeled messages and an empty ledger, Execution log prints one line per message (sourceMessageId, threadId, and state NEW) followed by:
{"selectedMessages":2,"moreMatches":false,"trackedItems":0,"draftsCreated":0} For each listed message ID, discovery calls Gmail.Users.Messages.get with format: 'metadata', metadataHeaders: ['Subject'], and a restricted fields mask so Gmail returns only the Subject header without fetching message bodies or attachments (see Gmail message formats; note that format: 'metadata' is a response format parameter, distinct from the gmail.metadata OAuth scope, which does not permit q search queries).
previewGmailWork reads User Properties without modifying the ledger, creating drafts, or changing labels.
Create the review drafts
After verifying the preview output, set allowDraftCreation to true, save Code.gs, select createGmailReviewDrafts, and click Run once. Reset allowDraftCreation to false and save immediately afterward.
For each untracked message in the selection, createGmailReviewDrafts creates:
An unsent draft addressed to your configured
recipient(with noccorbcc).A subject prefixed with a unique
BookGmailReview-marker.A plain-text body containing up to 160 characters of the original
Subjectheader and thesourceMessageId.A generated
review-note.txtattachment built in memory withUtilities.newBloband passed via theattachmentsoption ofGmailApp.createDraft.A ledger entry in User Properties with state
DRAFT_READYand the returneddraftId(seeGmailDraft).
Open Drafts in Gmail and confirm that both review drafts and their review-note.txt attachments are present. Leave both drafts unsent for the exercise below.
Build responsive HTML with MJML when it earns its cost
When a workflow requires formatted HTML email rather than plain text, MJML provides layout components (mj-section, mj-column, mj-text, mj-button) that compile into responsive email HTML. Pass both a plain-text body and the compiled htmlBody to GmailApp.createDraft(recipient, subject, plainTextBody, { htmlBody }):
const template = `
<mjml>
<mj-body>
<mj-section>
<mj-column>
<mj-text font-size="20px">Review ready</mj-text>
<mj-text>The requested report is ready for review.</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>`;
const output = mjml2html(template, { validationLevel: 'strict' });
if (output.errors && output.errors.length > 0) {
throw new Error('MJML validation failed: ' + JSON.stringify(output.errors));
}
GmailApp.createDraft(recipient, subject, plainTextBody, {
htmlBody: output.html
}); Because the standard mjml npm package depends on Node.js fs and path modules unavailable in V8, either precompile MJML templates to HTML during your local build or bundle mjml-browser with esbuild before pushing with clasp. Note that mjml-browser does not support filesystem-backed mj-include tags, and newer major versions may return a Promise from mjml2html; pin your mjml-browser version and verify its return type. Always HTML-escape untrusted values before interpolating them into an MJML template.
Note also that message.createDraftReply, thread.createDraftReply, and createDraftReplyAll address drafts to the sender or all thread participants. Creating a standalone draft with GmailApp.createDraft(recipient, ...) ensures the recipient is your explicit target address rather than an untrusted From or Reply-To header.
Keep the checkpoint beside its action
The project stores its per-message ledger array as a JSON string under the key gmail-workflows.v1 in PropertiesService.getUserProperties() (see the Properties guide).
For each selected message, createGmailReviewDrafts runs four steps in order:
Construct the review marker, plain-text body, and
Blobattachment in memory.Write a
CREATE_UNCERTAINentry withsourceMessageIdand the marker to User Properties before callingGmailApp.createDraft.Call
GmailApp.createDraftand readdraft.getId().Update that message's entry to
DRAFT_READYwithdraftIdin User Properties before advancing to the next message.
Writing the checkpoint after each message rather than once after the loop ensures that if the third message fails, the first two completed drafts are already recorded in User Properties:
| Saved state | What it records | Next creation run |
|---|---|---|
No record (NEW) | No attempt recorded in User Properties | May create a draft |
CREATE_UNCERTAIN | Intent saved before createDraft, without a confirmed completion write | Stops all new draft creation until reconciled |
DRAFT_READY | createDraft returned a draftId and the completion write succeeded | Skips this sourceMessageId |
CLOSED | You recorded a final resolution via recordGmailResolution | Skips this sourceMessageId |
State-changing functions acquire LockService.getUserLock() with tryLock(1000) and release it in finally so concurrent runs by the same user cannot overwrite the ledger.
After the exercise, review, keep, or send one draft
Complete the exercise in the next section while both drafts are still unsent. Afterward, you can keep, discard, or manually send a review draft in Gmail.
In automated code, draft.send(), GmailApp.sendEmail, and MailApp.sendEmail dispatch email immediately. When a draft is sent or updated, Gmail assigns a new message ID or replaces the underlying message inside the draft container (see the Gmail draft lifecycle), so the absence of a draft ID alone does not distinguish a sent draft from a deleted draft.
Record the observed outcome
To close a ledger item after inspecting it, run inspectGmailWork to copy its sourceMessageId and select one of the four resolution values:
resolution | When to use it |
|---|---|
KEPT_DRAFT | You inspected the matching draft and left it unsent |
SENT_CONFIRMED | You sent the draft and verified the matching message in Sent |
DISCARDED | You discarded the test draft in Gmail |
ABANDONED_UNCERTAIN | An interrupted attempt cannot be confirmed and should never be retried |
Set resolutionMessageId to that sourceMessageId, resolution to the chosen status, and resolutionConfirmation to 'CLOSE <sourceMessageId> AS <resolution>' (substituting the actual ID and status). Run recordGmailResolution, then reset all three fields to '' and save Code.gs. Run inspectGmailWork to confirm the item now shows CLOSED.
Reconcile uncertain creation or sending
If createGmailReviewDrafts fails mid-run, reset allowDraftCreation to false and run inspectGmailWork. Copy the BookGmailReview-... marker from the CREATE_UNCERTAIN entry and search Gmail with in:anywhere "BookGmailReview-...":
If you find the matching message in Sent (after a manual send), record
SENT_CONFIRMED.If you find the matching unsent draft in Drafts, inspect its recipient, body, and attachment, and record
KEPT_DRAFTorDISCARDEDdepending on whether you keep or delete it.If multiple matching drafts exist or the outcome remains unclear, delete any duplicate test drafts manually and record
ABANDONED_UNCERTAINso future runs skip thatsourceMessageId.
Handle quotas and ordinary failures
Each run lists one page of up to three messages, fetches up to three metadata headers, and creates up to three drafts. Google's Apps Script quota table tracks Gmail read/write operations separately from daily email send quotas. In scripts that send email via MailApp, MailApp.getRemainingDailyQuota() returns the approximate remaining recipient count (across to, cc, and bcc).
| Symptom | Response |
|---|---|
| Configuration error or account mismatch | Verify recipient matches the authenticated mailbox's primary address and maxMessages is 1, 2, or 3. |
Gmail is not defined or API-disabled error | Confirm enabledAdvancedServices in appsscript.json and ensure the Gmail API is enabled in any linked standard Cloud project. |
| Missing label or no selected messages | Confirm the BookGmailPractice label exists and is applied to non-draft messages matching the query. |
| Another run holds the lock | Wait for the active execution to finish in Executions, then run inspectGmailWork. |
| Creation or checkpoint error | Run inspectGmailWork, search Gmail by the logged BookGmailReview- marker, and close the item with recordGmailResolution. |
| Ledger full or invalid | Close or inspect the existing five tracked items in User Properties. |
Exercise: predict the bound and the repeat result
With two messages labeled BookGmailPractice and both created drafts still unsent:
Set
maxMessagesto1withallowDraftCreation: falseand runpreviewGmailWork. It selects1message and reportsmoreMatches: true, even if both labeled messages belong to the same thread.Change
maxMessagesto2and runpreviewGmailWork. It selects both message IDs withmoreMatches: false.Set
allowDraftCreationtotrue, runcreateGmailReviewDraftsonce, and resetallowDraftCreationtofalse. Because bothsourceMessageIdvalues already haveDRAFT_READYcheckpoints in User Properties, the run logsdraftsCreated: 0without creating duplicate drafts.Use
recordGmailResolutionto recordKEPT_DRAFTfor one of the twosourceMessageIdvalues, then runpreviewGmailWorkto see its state change fromDRAFT_READYtoCLOSED.
Retire only this exercise's resources
When finished, reset allowDraftCreation to false and clear the resolution fields in Code.gs. In Gmail, discard the two BookGmailReview- drafts, record DISCARDED for each via recordGmailResolution if desired, and delete the BookGmailPractice label (deleting the label leaves the original messages intact). Finally, move the Gmail review practice script project to Trash and optionally revoke its OAuth grant in your Google Account settings (see the authorization guide).
For related Gmail patterns, see the Sheet-row email guide, Gmail snippets, sender-labeling guide, and email-logging guide.
Next, create and reconcile Google Calendar events by stored event ID.
Project files
Complete source files for this chapter’s examples.
Prepare bounded Gmail review drafts
Code.gs
Download file// This file combines two Gmail surfaces:
// Manual, single-operator exercise. There is no send function.
const GMAIL_WORKFLOW = {
recipient: '', // Your test mailbox's primary address, not an alias or a list.
labelName: 'BookGmailPractice',
maxMessages: 3, // Allowed range: 1 through 3. Only one page is requested.
allowDraftCreation: false,
resolutionMessageId: '', // Copy sourceMessageId from inspectGmailWork().
resolution: '', // KEPT_DRAFT, SENT_CONFIRMED, DISCARDED, or ABANDONED_UNCERTAIN.
resolutionConfirmation: '' // CLOSE <sourceMessageId> AS <resolution>
};
/**
* @typedef {{
* sourceMessageId: string,
* draftId: string,
* marker: string,
* state: string,
* resolution: string,
* updatedAt: string
* }} GmailLedgerItem
*/
const GMAIL_WORKFLOW_LEDGER = 'gmail-workflows.v1';
const GMAIL_WORKFLOW_MAX_ITEMS = 5;
/** Read-only discovery: no draft, label, message, or property writes. */
function previewGmailWork() {
const recipient = requireGmailConfiguration_();
requireOwnMailbox_(recipient);
const ledger = readGmailLedger_();
const page = discoverGmailMessages_();
for (const message of page.messages) {
const item = ledger.find((entry) => entry.sourceMessageId === message.id);
Logger.log(JSON.stringify({
sourceMessageId: message.id,
threadId: message.threadId,
state: item ? item.state : 'NEW'
}));
}
Logger.log(JSON.stringify({
selectedMessages: page.messages.length,
moreMatches: page.moreMatches,
trackedItems: ledger.length,
draftsCreated: 0
}));
}
/** Creates at most three drafts addressed only to the configured own mailbox. */
function createGmailReviewDrafts() {
const recipient = requireGmailConfiguration_();
if (GMAIL_WORKFLOW.allowDraftCreation !== true) {
throw new Error('Draft creation is off. Preview first, then set allowDraftCreation to true.');
}
withGmailWorkflowLock_(() => {
requireOwnMailbox_(recipient);
const ledger = readGmailLedger_();
if (ledger.some((item) => item.state === 'CREATE_UNCERTAIN')) {
throw new Error('An earlier draft attempt is uncertain. Inspect and reconcile it before more creation.');
}
const page = discoverGmailMessages_();
const pending = page.messages.filter((message) =>
!ledger.some((item) => item.sourceMessageId === message.id)
);
if (ledger.length + pending.length > GMAIL_WORKFLOW_MAX_ITEMS) {
throw new Error('This exercise tracks at most five messages. Keep its markers; do not reset to retry.');
}
let created = 0;
for (const message of pending) {
const marker = 'BookGmailReview-' + Utilities.getUuid();
const item = {
sourceMessageId: message.id,
draftId: '',
marker: marker,
state: 'CREATE_UNCERTAIN',
resolution: '',
updatedAt: new Date().toISOString()
};
const subject = '[' + marker + '] Review selected message';
const body = [
'Private review note. This is not a reply to the original sender.',
'',
'Selected subject: ' + message.subject,
'Source message ID: ' + message.id,
'Review marker: ' + marker,
'',
'Decision: review later. No source message body or attachment was copied.'
].join('\n');
const attachment = Utilities.newBlob(
'Review marker: ' + marker + '\nDecision: review later.\n',
'text/plain',
'review-note.txt'
);
// Save intent BEFORE dispatch. A later run must not retry an unclear result.
ledger.push(item);
writeGmailLedger_(ledger);
try {
const draft = GmailApp.createDraft(recipient, subject, body, {
attachments: [attachment]
});
item.draftId = draft.getId();
item.state = 'DRAFT_READY';
item.updatedAt = new Date().toISOString();
// Checkpoint this item now, not after all draft creations.
writeGmailLedger_(ledger);
} catch (error) {
// Do not reset the marker or retry. The remote action may have succeeded.
Logger.log(JSON.stringify({ sourceMessageId: message.id, marker: marker }));
throw new Error('Draft creation or its checkpoint failed. Inspect this marker in Gmail before continuing.');
}
created += 1;
Logger.log(JSON.stringify(item));
}
Logger.log(JSON.stringify({
draftsCreated: created,
alreadyTracked: page.messages.length - pending.length,
moreMatches: page.moreMatches
}));
});
}
/** Reads saved observations only. It does not query Gmail or verify a draft exists. */
function inspectGmailWork() {
const ledger = readGmailLedger_();
for (const item of ledger) Logger.log(JSON.stringify(item));
Logger.log('Tracked items: ' + ledger.length);
}
/** Records the operator's review, without sending, deleting, or querying mail. */
function recordGmailResolution() {
const id = GMAIL_WORKFLOW.resolutionMessageId;
const resolution = GMAIL_WORKFLOW.resolution;
const outcomes = ['KEPT_DRAFT', 'SENT_CONFIRMED', 'DISCARDED', 'ABANDONED_UNCERTAIN'];
if (typeof id !== 'string' || !id.trim() || id.length > 200 || !outcomes.includes(resolution)) {
throw new Error('Set resolutionMessageId and one documented resolution after reviewing this item in Gmail.');
}
if (GMAIL_WORKFLOW.resolutionConfirmation !== 'CLOSE ' + id + ' AS ' + resolution) {
throw new Error('Set resolutionConfirmation to CLOSE <sourceMessageId> AS <resolution>.');
}
withGmailWorkflowLock_(() => {
const ledger = readGmailLedger_();
const item = ledger.find((entry) => entry.sourceMessageId === id);
if (!item) throw new Error('No tracked item has that sourceMessageId.');
if (item.state === 'CLOSED' && item.resolution === resolution) {
Logger.log('This resolution is already recorded. No write was needed.');
return;
}
item.state = 'CLOSED';
item.resolution = resolution;
item.updatedAt = new Date().toISOString();
writeGmailLedger_(ledger);
Logger.log(JSON.stringify(item));
});
}
function requireGmailConfiguration_() {
const recipient = GMAIL_WORKFLOW.recipient.trim().toLowerCase();
if (!/^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(recipient) ||
recipient.endsWith('@example.com')) {
throw new Error('Set recipient to your own test mailbox primary address, with no display name or recipient list.');
}
if (!/^[A-Za-z][A-Za-z0-9_-]{0,59}$/.test(GMAIL_WORKFLOW.labelName)) {
throw new Error('Use a label name of 1-60 letters, digits, underscores, or hyphens, starting with a letter.');
}
if (!Number.isInteger(GMAIL_WORKFLOW.maxMessages) ||
GMAIL_WORKFLOW.maxMessages < 1 || GMAIL_WORKFLOW.maxMessages > 3) {
throw new Error('maxMessages must be an integer from 1 through 3.');
}
return recipient;
}
/**
* @param {string} recipient
* @returns {void}
*/
function requireOwnMailbox_(recipient) {
const profile = Gmail.Users.getProfile('me');
if (!profile.emailAddress || profile.emailAddress.toLowerCase() !== recipient) {
throw new Error('recipient does not match the executing Gmail mailbox primary address. Stop and check the signed-in account.');
}
}
function discoverGmailMessages_() {
if (!GmailApp.getUserLabelByName(GMAIL_WORKFLOW.labelName)) {
throw new Error('Create the practice label in Gmail and apply it only to approved test messages.');
}
const query = 'label:' + GMAIL_WORKFLOW.labelName +
' -in:drafts -in:spam -in:trash -subject:BookGmailReview';
const page = Gmail.Users.Messages.list('me', {
q: query,
maxResults: GMAIL_WORKFLOW.maxMessages,
includeSpamTrash: false,
fields: 'messages(id,threadId),nextPageToken'
});
const references = page.messages || [];
if (references.length > GMAIL_WORKFLOW.maxMessages) {
throw new Error('Message listing exceeded the requested bound. No drafts were created.');
}
const messages = [];
for (const reference of references) {
if (!reference.id) throw new Error('A listed message has no usable ID.');
const message = Gmail.Users.Messages.get('me', reference.id, {
format: 'metadata',
metadataHeaders: ['Subject'],
fields: 'id,threadId,labelIds,payload/headers'
});
// The message may have moved since listing. Never turn a draft into an input.
const labels = message.labelIds || [];
if (labels.some((label) => ['DRAFT', 'SPAM', 'TRASH'].includes(label))) continue;
if (!message.id || !message.threadId) throw new Error('A listed message has no usable identity.');
const headers = message.payload && message.payload.headers ? message.payload.headers : [];
const subjectHeader = headers.find((header) => header.name && header.name.toLowerCase() === 'subject');
const subject = subjectHeader && subjectHeader.value ? subjectHeader.value : '(no subject)';
messages.push({
id: message.id,
threadId: message.threadId,
subject: subject.replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, 160)
});
}
return { messages: messages, moreMatches: Boolean(page.nextPageToken) };
}
/**
* @returns {GmailLedgerItem[]}
*/
function readGmailLedger_() {
const raw = PropertiesService.getUserProperties().getProperty(GMAIL_WORKFLOW_LEDGER);
if (raw === null) return [];
let ledger;
try {
ledger = JSON.parse(raw);
} catch (error) {
throw new Error('The saved ledger is not valid JSON. Preserve it and reconcile; do not clear it to retry.');
}
if (!Array.isArray(ledger) || ledger.length > GMAIL_WORKFLOW_MAX_ITEMS) {
throw new Error('The saved ledger has an unexpected shape. Preserve it and reconcile.');
}
const ids = new Set();
for (const item of ledger) {
if (!item || typeof item.sourceMessageId !== 'string' ||
!item.sourceMessageId.trim() || item.sourceMessageId.length > 200 || ids.has(item.sourceMessageId) ||
typeof item.draftId !== 'string' || item.draftId.length > 200 ||
typeof item.marker !== 'string' || !/^BookGmailReview-[a-f0-9-]{36}$/i.test(item.marker) ||
!['CREATE_UNCERTAIN', 'DRAFT_READY', 'CLOSED'].includes(item.state) ||
!['', 'KEPT_DRAFT', 'SENT_CONFIRMED', 'DISCARDED', 'ABANDONED_UNCERTAIN'].includes(item.resolution) ||
typeof item.updatedAt !== 'string' || !Number.isFinite(Date.parse(item.updatedAt)) ||
(item.state === 'DRAFT_READY' && !item.draftId) ||
(item.state === 'CLOSED' && !item.resolution)) {
throw new Error('A saved ledger item is invalid. Preserve it and reconcile; do not reset its marker.');
}
ids.add(item.sourceMessageId);
}
return ledger;
}
/**
* @param {GmailLedgerItem[]} ledger
* @returns {void}
*/
function writeGmailLedger_(ledger) {
const text = JSON.stringify(ledger);
if (ledger.length > GMAIL_WORKFLOW_MAX_ITEMS || text.length > 8000) {
throw new Error('The exercise ledger is full. No automatic marker deletion is allowed.');
}
PropertiesService.getUserProperties().setProperty(GMAIL_WORKFLOW_LEDGER, text);
}
/**
* @param {function(): void} operation
* @returns {void}
*/
function withGmailWorkflowLock_(operation) {
const lock = LockService.getUserLock();
if (!lock.tryLock(1000)) throw new Error('Another run is using this ledger. Wait for it to finish.');
try {
operation();
} finally {
lock.releaseLock();
}
}
appsscript.json
Download file{
"timeZone": "Etc/UTC",
"dependencies": {
"enabledAdvancedServices": [
{
"userSymbol": "Gmail",
"version": "v1",
"serviceId": "gmail"
}
]
},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"oauthScopes": [
"https://mail.google.com/"
]
}