Prepare Gmail work before sending
Build an Apps Script project that lists labeled test messages, creates review drafts with createDraft(), and saves each message's state.
Create review drafts for a limited number of labeled messages, and check each message's saved state before you rerun the script or send a draft.
Select labeled Gmail messages and create one unsent review draft for each, addressed to your own mailbox, with a generated text attachment. The script records a checkpoint for each message in User Properties, so a later run skips messages it already handled or left in an uncertain state. No function in this project sends email.
You need Understand the runtime and permissions first. Use per-item checkpoints for external actions, as explained in Read and write Sheets in batches.
Choose the Gmail service and scope
GmailApp searches conversations, reads messages, creates drafts, changes labels, and sends mail. 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 and never reads the inbox or creates drafts, use MailApp. Validate each recipient address before sending (see Invalid email).
This project searches labeled messages and creates drafts, so it uses two Gmail services:
Gmail, the advanced Gmail service, lists individual message IDs and fetches only each message'sSubjectheader.GmailApp, the built-in service, checks that the source label exists and builds the draft withcreateDraft(see missing required GmailApp arguments).
The manifest declares the https://mail.google.com/ scope, which authorizes both the GmailApp calls and the Gmail API calls used here (see the authorization guide).
Limit the run by message instead of by thread
In Gmail, a message is a single email and a thread is a conversation containing one or more messages.
GmailApp.search(query, start, max) counts threads. GmailApp.search('label:BookGmailPractice', 0, 3) returns up to three threads, and thread.getMessages() loads every message in a thread, however many replies it holds.
To limit the number of messages instead, this project calls Gmail.Users.Messages.list('me', { ... }) with maxResults set to maxMessages, a number from 1 to 3. The response lists message IDs and thread IDs. When more messages match the query than the page holds, the response carries a nextPageToken, and the script logs moreMatches: true.
The script keeps a ledger in User Properties. The ledger is a list of checkpoints, one per message. Several labeled messages can share one thread, so the ledger keys each checkpoint on the source message ID (sourceMessageId). Each checkpoint also stores the created draft ID (draftId). The script logs threadId only to help with troubleshooting. The ledger holds at most five messages over the life of the project.
Use a label to select messages
A Gmail label is a tag on a message. Unlike a folder, a label does not contain the message, so a message can carry several labels. The built-in GmailApp service adds and reads labels one thread at a time. This project uses the Gmail advanced service instead so it can inspect each message's own labels. The script checks that the label BookGmailPractice exists in the mailbox and then lists messages with this query:
label:BookGmailPractice -in:drafts -in:spam -in:trash -subject:BookGmailReview Each leading - excludes something: drafts, spam, trash, and the BookGmailReview subjects that earlier runs created.
Test the query in the Gmail search bar first, but expect small differences. The Gmail web UI matches across whole threads and expands aliases, while the REST API does neither (see the Gmail filtering guide). Applying a label to a conversation in the UI tags the messages currently in that thread. Later replies do not inherit the label, and drafts cannot hold custom labels (see Manage labels).
Prepare a test mailbox and 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.Confirm that Gmail API appears under Services in the left sidebar. The manifest's
enabledAdvancedServicesentry makes Gmail APIv1available as theGmailglobal. If your script is linked to a standard Google Cloud project instead of 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, with no display name. KeepallowDraftCreation: false,maxMessages: 3, and the three resolution fields (resolutionMessageId,resolution,resolutionConfirmation) set to''.
Before it lists messages, the script checks that recipient is a plain address and that maxMessages is 1, 2, or 3. It also checks that recipient matches the signed-in 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. Authorize the project when prompted. With two labeled messages and an empty ledger, the Execution log prints one line per message (sourceMessageId, threadId, and state NEW). It then prints this summary:
{"selectedMessages":2,"moreMatches":false,"trackedItems":0,"draftsCreated":0} For each listed message ID, the script calls Gmail.Users.Messages.get with format: 'metadata', metadataHeaders: ['Subject'], and a fields mask. Gmail then returns the Subject header and label IDs, and no message body or attachment (see Gmail message formats). format: 'metadata' is a response format parameter. It is not the gmail.metadata OAuth scope, which does not permit q search queries.
previewGmailWork reads the ledger from User Properties. It does not change the ledger, create drafts, or change labels.
Create the review drafts
After you check the preview output, set allowDraftCreation to true, save Code.gs, select createGmailReviewDrafts, and click Run once. Then set allowDraftCreation back to false and save.
For each selected message that has no ledger entry, createGmailReviewDrafts creates:
An unsent draft addressed to your configured
recipient, with noccorbcc.A subject that starts with a unique
BookGmailReview-marker.A plain-text body with up to 160 characters of the original
Subjectheader and thesourceMessageId.A
review-note.txtattachment built in memory withUtilities.newBloband passed in theattachmentsoption ofGmailApp.createDraft.A ledger entry in User Properties with state
DRAFT_READYand the returneddraftId(seeGmailDraft).
message.createDraftReply, thread.createDraftReply, and createDraftReplyAll address a draft to the sender or to every thread participant. This project instead calls GmailApp.createDraft(recipient, ...). The address is always the recipient you set and never comes from a From or Reply-To header.
Open Drafts in Gmail and confirm that both review drafts are there, each with its review-note.txt attachment. Leave both drafts unsent for the exercise below.
Optional: build HTML email with MJML
When a workflow needs formatted HTML email, MJML layout components (mj-section, mj-column, mj-text, mj-button) compile into responsive email HTML. Pass a plain-text body and the compiled HTML 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
}); The standard mjml npm package needs the Node.js fs and path modules, which Apps Script's V8 runtime does not have. You have two options:
Compile MJML templates to HTML during your local build.
Bundle
mjml-browserwith esbuild before you push withclasp.
mjml-browser does not support mj-include tags, which read from the filesystem. A newer major version may return a Promise from mjml2html, so pin your mjml-browser version and check its return type. HTML-escape untrusted values before you insert them into an MJML template.
Save a checkpoint around each draft
The project stores the ledger as a JSON array string under the key gmail-workflows.v1 in User Properties, which PropertiesService.getUserProperties() returns (see the Properties guide).
For each selected message, createGmailReviewDrafts runs four steps in order:
Build the review marker, plain-text body, and
Blobattachment in memory.Write a
CREATE_UNCERTAINentry with thesourceMessageIdand the marker to the ledger, before callingGmailApp.createDraft.Call
GmailApp.createDraftand readdraft.getId().Update that entry to
DRAFT_READYwith thedraftId, before moving to the next message.
The script writes a checkpoint after each message, not once after the loop. If the third message fails, the first two drafts are already recorded. Each saved state tells the next creation run what to do:
| Saved state | What it records | Next creation run |
|---|---|---|
No record (NEW) | No attempt recorded | May create a draft |
CREATE_UNCERTAIN | The marker was saved before createDraft, and no completion was recorded | Creates no drafts at all until you reconcile this entry |
DRAFT_READY | createDraft returned a draftId, and the completion was recorded | Skips this sourceMessageId |
CLOSED | You recorded a final resolution with recordGmailResolution | Skips this sourceMessageId |
createGmailReviewDrafts and recordGmailResolution take LockService.getUserLock() with tryLock(1000) and release it in finally, so two runs by the same user cannot overwrite each other's ledger writes.
After the exercise, review, keep, or send one draft
Do the exercise below while both drafts are still unsent. Afterward, you can keep, discard, or send a review draft by hand in Gmail.
In code, draft.send(), GmailApp.sendEmail, and MailApp.sendEmail send email at once. When a draft is sent or updated, Gmail assigns a new message ID or replaces the message inside the draft. See the Gmail drafts guide. A draft ID that no longer resolves therefore does not tell you whether the draft was sent or deleted.
Record the observed outcome
To close a ledger item, run inspectGmailWork, copy the item's sourceMessageId, and pick 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 |
In Code.gs, set resolutionMessageId to that sourceMessageId and resolution to the chosen value. Set resolutionConfirmation to 'CLOSE <sourceMessageId> AS <resolution>' with the real ID and value filled in. Save, then run recordGmailResolution. Set all three fields back to '' and save again. Run inspectGmailWork to confirm the item now shows state CLOSED.
Reconcile uncertain creation or sending
If createGmailReviewDrafts fails partway through, set allowDraftCreation to false and run inspectGmailWork. Copy the BookGmailReview-... marker from the CREATE_UNCERTAIN entry and search Gmail for in:anywhere "BookGmailReview-...". Then record what you find:
If the matching message is in Sent after a manual send, record
SENT_CONFIRMED.If the matching unsent draft is in Drafts, check its recipient, body, and attachment. Record
KEPT_DRAFTif you keep it orDISCARDEDif you delete it.If several drafts match, or you cannot tell what happened, delete the duplicate test drafts by hand and record
ABANDONED_UNCERTAIN, so later runs skip thatsourceMessageId.
Handle quotas and ordinary failures
Each run lists one page of up to three messages, fetches metadata for each, and creates up to three drafts. Google's Apps Script quota table counts Gmail read and write operations separately from the daily email send quota. In a script that sends email with MailApp, MailApp.getRemainingDailyQuota() returns the approximate number of recipients (to, cc, and bcc together) the account can still send to today.
| Symptom | Response |
|---|---|
| Configuration error or account mismatch | Check that recipient is the signed-in mailbox's primary address and that maxMessages is 1, 2, or 3. |
Gmail is not defined or an API-disabled error | Check enabledAdvancedServices in appsscript.json. If the script is linked to a standard Cloud project, enable the Gmail API there. |
| Missing label or no selected messages | Check that the BookGmailPractice label exists and is applied to non-draft messages that match 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 for the logged BookGmailReview- marker, and close the item with recordGmailResolution. |
| Ledger full or invalid | The ledger holds at most five messages for the life of the project, and closed items still count. Run inspectGmailWork to review them. Do not clear the property to retry, because the markers are needed for reconciliation. |
Exercise: change the message limit and rerun
With two messages labeled BookGmailPractice and both created drafts still unsent:
Set
maxMessagesto1, keepallowDraftCreation: false, and runpreviewGmailWork. It selects one message and reportsmoreMatches: true, even when both labeled messages are in the same thread.Set
maxMessagesto2and runpreviewGmailWork. It selects both message IDs and reportsmoreMatches: false.Set
allowDraftCreationtotrue, runcreateGmailReviewDraftsonce, and setallowDraftCreationback tofalse. BothsourceMessageIdvalues already haveDRAFT_READYentries, so the run logsdraftsCreated: 0and creates no duplicate drafts.Record
KEPT_DRAFTfor one of the twosourceMessageIdvalues withrecordGmailResolution, then runpreviewGmailWork. That message's state changes fromDRAFT_READYtoCLOSED.
Clean up the practice resources
When you are done:
Set
allowDraftCreationtofalseand clear the three resolution fields inCode.gs.In Gmail, discard the two
BookGmailReview-drafts.If you want the ledger to match, record
DISCARDEDfor each withrecordGmailResolution.Delete the
BookGmailPracticelabel. This leaves the messages in place.Move the
Gmail review practicescript project to Trash.
You can also 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.
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/"
]
}