Build a Gmail Triage Dashboard
Build a private web dashboard to label, read, and archive Gmail threads.
Introduction
30 Second Summary
Inbox messages often arrive faster than you can sort them. Follow-up work can disappear beneath newer conversations before you act on it.
In this project, you will build a private Gmail dashboard with Google Apps Script. You will run it in Microsoft Edge to process live conversations from one screen.
What You'll Build
Your private dashboard will turn live inbox conversations into a focused queue with visible results for every action.
By the end of this project, you'll have:
- A live inbox snapshot that shows the sender, subject, date, plus unread state for up to 10 conversations.
- A one-click Action workflow that turns unread conversations into labeled follow-up work.
- A synchronized archive flow with clear status feedback at every stage.
- Secret Mission: Add an instant filter that narrows the loaded inbox by sender or subject.
Are there any prerequisites?
You need a Google account with Gmail enabled in Microsoft Edge. Google Apps Script runs in the browser with no local installation or paid service.
Before We Start
Before any hands-on work, lock in how your private Gmail triage dashboard will improve the way you process your inbox. This commitment gives every later decision a clear purpose.
Set Up Your Private Web App
Your triage dashboard needs a private browser home before it can read any inbox data. This step creates that home without touching your Gmail threads.
Google Apps Script gives you a browser editor backed by Google's hosted runtime. You will use it to serve a starter page in Microsoft Edge.
In this step, get ready to:
- Create a standalone Apps Script project named Gmail Triage Dashboard.
- Build a starter page from Code.gs plus Index.html.
- Open a private test deployment in Microsoft Edge.
Create the Apps Script project
A standalone Apps Script project stores your server code plus browser files in Google Drive. Google handles the sign-in screen so your credentials stay outside the project.
- Press Cmd+Space on macOS or the Windows key on Windows to open your search bar.
- Type Microsoft Edge and press Enter to open it.
- Enter script.google.com in the address bar to visit the Apps Script editor.
- Sign in with the Google account whose Gmail inbox you plan to manage.
- Select New project in the Apps Script home page.
- Rename the project to Gmail Triage Dashboard using the project name at the top of the editor.
That first checkpoint is complete. Your project now has a permanent home in Google Drive with Code.gs ready in the file list.
Why Google Apps Script?
Apps Script provides the editor plus the hosted runtime inside your browser. Google runs the project without a local software installation.
A direct Gmail API browser client adds a Google Cloud project. It also adds OAuth configuration plus client credentials before you can focus on inbox triage.
Build the starter page
A web app needs a server entry point that returns a page to the browser. The doGet() function uses HTML Service to serve that page.
- Select Code.gs from the file list on the left.
- Replace the contents of Code.gs by pasting the code below:
function doGet() {
return HtmlService.createHtmlOutputFromFile('Index')
.setTitle('Gmail Triage Dashboard');
}
What does this server code do?
- The doGet() function runs when the browser requests the web app.
- HtmlService.createHtmlOutputFromFile('Index') loads the contents of Index.html.
- setTitle('Gmail Triage Dashboard') sets the title shown in the browser tab.
- Save Code.gs using the save control in the editor toolbar.
- Confirm the editor indicates that your latest project changes are saved.
Server code not saving?
Check that every opening brace has a matching closing brace. Confirm that the two string values still use matching single quotes.
Help me debug my Apps Script doGet function.
✔️ Awesome, I've got everything!
Great. Your saved Code.gs file now provides the web app entry point.
ⓧ I'd like to double check the full code
- Compare your saved Code.gs with this complete reference:
function doGet() {
return HtmlService.createHtmlOutputFromFile('Index')
.setTitle('Gmail Triage Dashboard');
}
What should match?
Your complete server file should contain one doGet() function. It should load Index with the browser title Gmail Triage Dashboard.
The server now knows which page to return. Next you need the Index.html file that supplies the visible heading plus starter message.
- Select Add a file in the file list on the left.
- Select HTML as the file type.
- Enter Index as the file name to create Index.html.
You will see Index.html beside Code.gs in the file list. That confirms the browser file belongs to this project.
- Replace the contents of Index.html by pasting the code below:
<!DOCTYPE html>
<html>
<head>
<base target="_top">
</head>
<body>
<main>
<h1>Gmail Triage Dashboard</h1>
<p>Your private Gmail triage dashboard is ready to connect.</p>
</main>
</body>
</html>
What does this page contain?
- The <base target="_top"> element keeps links within the top-level browser window.
- The <main> element groups the dashboard's visible content.
- The <h1> element displays the dashboard name.
- The <p> element displays the starter connection message.
- Save Index.html using the save control in the editor toolbar.
- Confirm the editor indicates that both project files are saved.
HTML file not looking right?
Confirm that the file is named Index.html with a capital I. Check that every opening HTML tag has its matching closing tag.
Help me compare my starter HTML with the required structure.
✔️ Awesome, I've got everything!
Both starter files are now saved inside your Apps Script project.
ⓧ I'd like to double check the full code
- Compare your saved Index.html with this complete reference:
<!DOCTYPE html>
<html>
<head>
<base target="_top">
</head>
<body>
<main>
<h1>Gmail Triage Dashboard</h1>
<p>Your private Gmail triage dashboard is ready to connect.</p>
</main>
</body>
</html>
What should match?
Your complete browser file should contain the heading plus starter message inside <main>. No inbox controls or Gmail data belong on this starter page.
Open the private test deployment
A test deployment always runs the most recently saved code. Its URL ends in /dev.
Only people with edit access to the project can use this test URL. That restriction keeps the learning prototype private while you build it.
- Save the project using the save control in the editor toolbar.
- Select Deploy in the top-right corner of the editor.
- Select Test deployments from the deployment menu.
- Choose Web app as the deployment type.
- Copy the URL shown in the test deployment panel.
- Record the test URL here: your Apps Script test web app URL.
Before you open the URL, what page do you expect the saved doGet() function to return?
- Open a new tab in Microsoft Edge.
- Paste your Apps Script test web app URL into the address bar.
- Press Enter to load the test deployment.
You will see Gmail Triage Dashboard above Your private Gmail triage dashboard is ready to connect. This proves the private web app can serve its first page.
Your browser workspace is live. You now have a visible foundation for the Gmail connection that comes next.
Test page not loading?
Return to the Apps Script editor from earlier. Confirm that Code.gs plus Index.html are saved.
Check that doGet() requests Index with the same capitalization as the file name.
Help me debug why my Apps Script test web app is not showing the starter page.
Your private web app is ready. Next you will replace the starter message with a live read-only snapshot of your inbox.
Display a Live Inbox Snapshot
Your private Google Apps Script web app is live in Microsoft Edge. Now it needs real inbox data to become useful.
The starter page cannot reveal what needs your attention. In this step, you will connect it to Gmail as a read-only snapshot before adding mailbox actions.
In this step, get ready to:
- Retrieve up to 10 inbox threads as browser-safe objects.
- Render each thread with its sender, subject, date, and unread state.
- Handle loading, success, empty, and failure states.
Connect the server to Gmail
The browser cannot work directly with Apps Script Gmail objects. Your server function converts each thread into a plain object containing only the details the dashboard needs.
- Switch back to the Apps Script project from the previous step.
- Select Code.gs in the editor's file list.
- Place your cursor below the closing brace of doGet().
- Add the inbox retrieval function by pasting this code:
function getInboxThreads() {
const threads = GmailApp.getInboxThreads(0, 10);
return threads.map(function(thread) {
const messages = thread.getMessages();
const latestMessage = messages[messages.length - 1];
return {
id: thread.getId(),
subject: thread.getFirstMessageSubject() || '(No subject)',
sender: latestMessage.getFrom(),
date: latestMessage.getDate().toISOString(),
unread: thread.isUnread(),
};
});
}
What does this code do?
- The paged inbox request retrieves no more than 10 Gmail threads.
- The latest message supplies the sender and date shown in each row.
- The date becomes ISO text because browser-to-server calls cannot return a Date object.
- The returned object excludes message bodies and attachments.
- Save Code.gs using the editor's save control.
- Open the editor's function picker.
- Confirm that getInboxThreads appears as an available function.
Your server now has a browser-callable function for reading the inbox snapshot.
Function missing from the picker?
Check that getInboxThreads() sits outside doGet(). A missing closing brace can accidentally nest one function inside the other.
Confirm that every opening brace has a matching closing brace.
Help me check my Apps Script function structure.
✔️ Awesome, I've got everything!
Great. Your saved server code can now retrieve a limited inbox snapshot.
ⓧ I'd like to double check the full code
Compare your complete Code.gs file with this reference.
function doGet() {
return HtmlService.createHtmlOutputFromFile('Index')
.setTitle('Gmail Triage Dashboard');
}
function getInboxThreads() {
const threads = GmailApp.getInboxThreads(0, 10);
return threads.map(function(thread) {
const messages = thread.getMessages();
const latestMessage = messages[messages.length - 1];
return {
id: thread.getId(),
subject: thread.getFirstMessageSubject() || '(No subject)',
sender: latestMessage.getFrom(),
date: latestMessage.getDate().toISOString(),
unread: thread.isUnread(),
};
});
}
Build the dashboard shell
The browser needs separate places for request feedback and inbox rows. A status panel communicates progress while the thread list holds data returned by the server.
- Select Index.html in the editor's file list.
- Place your cursor directly below <base target="_top">.
- Add the dashboard's base styling by pasting this code:
<style>
:root {
color: #202124;
font-family: Arial, sans-serif;
}
body {
background: #f6f8fc;
margin: 0;
padding: 24px;
}
main {
margin: 0 auto;
max-width: 900px;
}
.status {
background: #fff;
border-radius: 10px;
margin-bottom: 12px;
padding: 12px 14px;
}
</style>
What does this styling do?
The page gets a light background with a centered content area. The status panel becomes a distinct surface for loading messages and request results.
- Save Index.html.
- Switch back to the test web app URL from the previous step.
- Reload the page.
You will see the starter content on a light background inside a centered column.
- Return to Index.html in the Apps Script editor.
- Replace the existing <body> section with this dashboard structure:
<body>
<main>
<header>
<div>
<h1>Gmail Triage Dashboard</h1>
<p>Label follow-up work, mark it read, or archive it.</p>
</div>
</header>
<div class="status" id="status" aria-live="polite">Loading your inbox...</div>
<section class="thread-list" id="threadList" aria-label="Inbox threads"></section>
</main>
</body>
How is the page organized?
- The status element reports loading, success, empty, or failure information.
- The threadList section receives the rows created from Gmail data.
- The live-region attribute lets assistive technology announce status updates.
- Save Index.html.
- Switch back to the test web app URL.
- Reload the page.
You will see the updated description with a white panel that says Loading your inbox....
Dashboard shell not updating?
Confirm that you are reloading the test URL from the previous step. The saved project code must belong to the same test deployment.
Check that the <style> block remains inside <head>.
Help me find why my saved Index.html changes are missing from the test web app.
Load and render inbox threads
The page now needs an asynchronous bridge to the server. google.script.run requests the thread objects while the browser stays responsive.
Each returned value becomes a DOM element. Using textContent keeps Gmail-derived strings as text instead of interpreting them as HTML.
- Place your cursor directly before </body> in Index.html.
- Add the initial inbox request and empty-state renderer by pasting this code:
<script>
function loadInbox() {
setStatus('Loading your inbox...', '');
google.script.run
.withSuccessHandler(function(threads) {
renderThreads(threads);
setStatus(threads.length + ' inbox thread(s) loaded.', 'success');
})
.withFailureHandler(function(error) {
setStatus(error.message || 'Could not load the inbox.', 'error');
})
.getInboxThreads();
}
function renderThreads(threads) {
const list = document.getElementById('threadList');
list.replaceChildren();
if (threads.length === 0) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = 'No inbox threads were returned.';
list.appendChild(empty);
return;
}
}
</script>
How does the request flow work?
- The success handler passes the returned array into renderThreads().
- The failure handler turns a server problem into a visible error state.
- The empty branch replaces a blank page with a clear inbox result.
- Place your cursor above </script>.
- Add the status helper and page-load event by pasting this code:
function setStatus(message, type) {
const status = document.getElementById('status');
status.textContent = message;
status.className = type ? 'status ' + type : 'status';
}
window.addEventListener('load', loadInbox);
What starts the inbox request?
The page-load event calls loadInbox() when the dashboard opens. The status helper updates the live region with plain text returned by each request state.
This authorization connects your private project to mailbox data. Check the selected Google account carefully so the dashboard reads only the inbox you intend to manage.
- Save Index.html.
- Switch back to the test web app URL.
- Reload the page.
- Choose the intended Google account if an authorization prompt appears.
- Review the requested Gmail access.
- Approve the access to continue.
You will see a loaded thread count after the request finishes. An inbox with no current threads shows the empty-state message instead.
Inbox request not completing?
Confirm that the authorized account has Gmail enabled. Check that the same account has edit access to this private test deployment.
If the status reports a failure, compare the method names in Code.gs with the saved server function.
Help me diagnose why my private Apps Script web app cannot load Gmail threads.
- Return to the renderThreads(threads) function in Index.html.
- Place your cursor after the empty-state if block.
- Render the returned metadata by pasting this loop:
threads.forEach(function(thread) {
const row = document.createElement('article');
row.className = thread.unread ? 'thread unread' : 'thread';
const details = document.createElement('div');
const subject = document.createElement('p');
subject.className = 'subject';
subject.textContent = thread.subject;
const sender = document.createElement('p');
sender.className = 'meta';
sender.textContent = 'From: ' + thread.sender;
const date = document.createElement('p');
date.className = 'meta';
date.textContent = new Date(thread.date).toLocaleString();
details.append(subject, sender, date);
const actions = document.createElement('div');
actions.className = 'actions';
row.append(details, actions);
list.appendChild(row);
});
How are thread rows rendered safely?
- Each thread gets its own article element.
- The subject and sender use textContent so email data stays plain text.
- The ISO date becomes a local date and time in Microsoft Edge.
- The unread value adds a class that the next styling rule can reveal.
- Save Index.html.
- Switch back to the test web app URL.
- Reload the page.
You will see up to 10 rows containing real subjects, senders, and dates from the authorized inbox.
- Return to the <style> block in Index.html.
- Place your cursor directly before </style>.
- Style the request result and thread cards by pasting these rules:
.status.success {
color: #137333;
}
.status.error {
color: #b3261e;
}
.thread-list {
display: grid;
gap: 10px;
}
.thread {
align-items: center;
background: #fff;
border-radius: 12px;
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr) auto;
padding: 14px;
}
What changes on the dashboard?
Success and failure messages gain distinct colors. Each returned thread becomes a separate white card in a spaced grid.
- Save Index.html.
- Switch back to the test web app URL.
- Reload the page.
You will see the loaded count in green with each inbox thread inside its own card.
- Return to the thread loop in renderThreads(threads).
- Place your cursor below details.append(subject, sender, date);.
- Add the unread indicator by pasting this conditional block:
if (thread.unread) {
const badge = document.createElement('span');
badge.className = 'badge';
badge.textContent = 'Unread';
details.appendChild(badge);
}
How does the unread indicator work?
The badge exists only when the returned unread value is true. Read threads remain uncluttered.
- Return to the end of the <style> block.
- Style unread rows and the empty state by pasting these rules:
.thread.unread {
border-left: 5px solid #0b57d0;
}
.badge {
background: #e8f0fe;
border-radius: 12px;
color: #0b57d0;
display: inline-block;
font-size: 12px;
margin-top: 6px;
padding: 3px 8px;
}
.empty {
background: #fff;
border-radius: 12px;
color: #5f6368;
padding: 24px;
text-align: center;
}
What do these states show?
Unread cards gain a blue edge with a compact badge. An empty inbox gets a centered message instead of a blank list.
- Find the existing loadInbox() function.
- Replace the entire function with this completed version:
function loadInbox() {
setBusy(true);
setStatus('Loading your inbox...', '');
google.script.run
.withSuccessHandler(function(threads) {
renderThreads(threads);
setStatus(threads.length + ' inbox thread(s) loaded.', 'success');
setBusy(false);
})
.withFailureHandler(function(error) {
setStatus(error.message || 'Could not load the inbox.', 'error');
setBusy(false);
})
.getInboxThreads();
}
Why track the busy state?
The request enters a busy state before contacting the server. Both response paths clear that state when the asynchronous call finishes.
- Place your cursor below the setStatus(message, type) function.
- Add the busy-state helper by pasting this function:
function setBusy(isBusy) {
document.querySelectorAll('button').forEach(function(button) {
button.disabled = isBusy;
});
}
What does the busy helper control?
The helper applies one request state to every dashboard button. This prevents overlapping requests when controls are added to the rows.
Before you reload, how many inbox rows do you expect the dashboard to display at most?
- Save Index.html.
- Switch back to the private test web app URL.
- Reload the page.
- Compare the loaded rows with the intended Gmail inbox.
You will see up to 10 live inbox rows. Each row shows a subject, sender, and local date.
Unread threads also show a blue edge with an Unread badge. The loaded count confirms that the browser received the current snapshot.
Thread details missing or incorrect?
Confirm that the metadata loop sits inside renderThreads(threads) after the empty-state block. Check that the unread block sits inside the loop.
If the status turns red, inspect Code.gs for a misspelled Gmail method or a missing brace.
Help me debug my live Gmail thread rendering.
✔️ Awesome, I've got everything!
Excellent. Save Index.html once more before continuing.
ⓧ I'd like to double check the full code
Compare your complete Index.html file with this step's finished version.
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<style>
:root {
color: #202124;
font-family: Arial, sans-serif;
}
body {
background: #f6f8fc;
margin: 0;
padding: 24px;
}
main {
margin: 0 auto;
max-width: 900px;
}
.status {
background: #fff;
border-radius: 10px;
margin-bottom: 12px;
padding: 12px 14px;
}
.status.success {
color: #137333;
}
.status.error {
color: #b3261e;
}
.thread-list {
display: grid;
gap: 10px;
}
.thread {
align-items: center;
background: #fff;
border-radius: 12px;
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr) auto;
padding: 14px;
}
.thread.unread {
border-left: 5px solid #0b57d0;
}
.badge {
background: #e8f0fe;
border-radius: 12px;
color: #0b57d0;
display: inline-block;
font-size: 12px;
margin-top: 6px;
padding: 3px 8px;
}
.empty {
background: #fff;
border-radius: 12px;
color: #5f6368;
padding: 24px;
text-align: center;
}
</style>
</head>
<body>
<main>
<header>
<div>
<h1>Gmail Triage Dashboard</h1>
<p>Label follow-up work, mark it read, or archive it.</p>
</div>
</header>
<div class="status" id="status" aria-live="polite">Loading your inbox...</div>
<section class="thread-list" id="threadList" aria-label="Inbox threads"></section>
</main>
<script>
function loadInbox() {
setBusy(true);
setStatus('Loading your inbox...', '');
google.script.run
.withSuccessHandler(function(threads) {
renderThreads(threads);
setStatus(threads.length + ' inbox thread(s) loaded.', 'success');
setBusy(false);
})
.withFailureHandler(function(error) {
setStatus(error.message || 'Could not load the inbox.', 'error');
setBusy(false);
})
.getInboxThreads();
}
function renderThreads(threads) {
const list = document.getElementById('threadList');
list.replaceChildren();
if (threads.length === 0) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = 'No inbox threads were returned.';
list.appendChild(empty);
return;
}
threads.forEach(function(thread) {
const row = document.createElement('article');
row.className = thread.unread ? 'thread unread' : 'thread';
const details = document.createElement('div');
const subject = document.createElement('p');
subject.className = 'subject';
subject.textContent = thread.subject;
const sender = document.createElement('p');
sender.className = 'meta';
sender.textContent = 'From: ' + thread.sender;
const date = document.createElement('p');
date.className = 'meta';
date.textContent = new Date(thread.date).toLocaleString();
details.append(subject, sender, date);
if (thread.unread) {
const badge = document.createElement('span');
badge.className = 'badge';
badge.textContent = 'Unread';
details.appendChild(badge);
}
const actions = document.createElement('div');
actions.className = 'actions';
row.append(details, actions);
list.appendChild(row);
});
}
function setStatus(message, type) {
const status = document.getElementById('status');
status.textContent = message;
status.className = type ? 'status ' + type : 'status';
}
function setBusy(isBusy) {
document.querySelectorAll('button').forEach(function(button) {
button.disabled = isBusy;
});
}
window.addEventListener('load', loadInbox);
</script>
</body>
</html>
That connection is working. Your private dashboard can now turn live Gmail data into a readable inbox snapshot.
Next, you will add a focused workflow for labeling follow-up threads and clearing their unread state.
Add the Action Workflow
Your dashboard now turns live Gmail threads into a readable inbox snapshot. Processing a thread still sends you back to Gmail.
This step adds an Action workflow through Google Apps Script. An asynchronous client-server call applies a follow-up label without interrupting the dashboard page.
In this step, get ready to:
- Create a server function that labels a selected thread.
- Connect an Action button to each dashboard row.
- Compare the changed Gmail thread with the stale dashboard row.
Create the server-side action
The server needs one helper that finds the existing Action label. It creates the label only when no matching user label exists.
- Return to the Apps Script editor from earlier.
- Select Code.gs in the editor sidebar.
- Place your cursor after the closing brace of getInboxThreads().
- Add the private label helper by pasting this code:
function getOrCreateActionLabel_() {
const labels = GmailApp.getUserLabels();
const actionLabel = labels.find(function(label) {
return label.getName() === 'Action';
});
return actionLabel || GmailApp.createLabel('Action');
}
What does this helper do?
- The labels array holds every user-created Gmail label.
- The find() callback looks for a label whose name is exactly Action.
- The final return reuses the matching label. It creates the label when no match exists.
- The trailing underscore keeps getOrCreateActionLabel_() private from browser calls through google.script.run.
- Save Code.gs.
- Confirm the editor accepts the helper without highlighting a syntax problem.
Seeing a syntax problem in the helper?
Check that getOrCreateActionLabel_() sits outside getInboxThreads(). Each function needs its own closing brace.
Check that the label name uses matching single quotes around Action.
Help me fix the label helper.
The public server function receives the selected thread ID from the browser. It stops with a clear error if that Gmail thread no longer exists.
- Place your cursor directly above getOrCreateActionLabel_() in Code.gs.
- Add the public Action function by pasting this code:
function applyAction(threadId) {
const thread = GmailApp.getThreadById(threadId);
if (!thread) {
throw new Error('Thread not found. Refresh the dashboard and try again.');
}
const label = getOrCreateActionLabel_();
label.addToThread(thread);
thread.markRead();
return 'Action label applied and thread marked read.';
}
What does the Action function do?
- The threadId parameter identifies the dashboard row selected in the browser.
- The missing-thread check prevents the remaining mailbox actions from running with an invalid result.
- The helper returns the reusable Action label.
- The function applies the label. It clears the thread's unread state before returning a success message.
- Save Code.gs.
- Confirm the editor accepts applyAction() without highlighting a syntax problem.
Is the server function incomplete?
Make sure applyAction() appears above getOrCreateActionLabel_(). Both functions must remain outside getInboxThreads().
Check the capitalization of GmailApp. Check the capitalization of getThreadById().
Help me debug the server-side Action workflow.
✔️ Awesome, I've got everything!
Your server workflow is saved. The Action label is still created only when a browser request successfully reaches applyAction().
ⓧ I'd like to double check the full code
function doGet() {
return HtmlService.createHtmlOutputFromFile('Index')
.setTitle('Gmail Triage Dashboard');
}
function getInboxThreads() {
const threads = GmailApp.getInboxThreads(0, 10);
return threads.map(function(thread) {
const messages = thread.getMessages();
const latestMessage = messages[messages.length - 1];
return {
id: thread.getId(),
subject: thread.getFirstMessageSubject() || '(No subject)',
sender: latestMessage.getFrom(),
date: latestMessage.getDate().toISOString(),
unread: thread.isUnread(),
};
});
}
function applyAction(threadId) {
const thread = GmailApp.getThreadById(threadId);
if (!thread) {
throw new Error('Thread not found. Refresh the dashboard and try again.');
}
const label = getOrCreateActionLabel_();
label.addToThread(thread);
thread.markRead();
return 'Action label applied and thread marked read.';
}
function getOrCreateActionLabel_() {
const labels = GmailApp.getUserLabels();
const actionLabel = labels.find(function(label) {
return label.getName() === 'Action';
});
return actionLabel || GmailApp.createLabel('Action');
}
How should the functions be ordered?
The existing page and inbox functions remain first. The new public action appears above its private label helper.
Connect each row to the server
The browser needs a reusable bridge between a row button and the matching server function. Success handlers show the returned message while failure handlers turn server errors into visible feedback.
- Select Index.html in the Apps Script editor sidebar.
- Find the closing brace of renderThreads(threads).
- Add the client-side action function directly below that closing brace by pasting this code:
function runThreadAction(actionName, threadId) {
setBusy(true);
setStatus('Updating Gmail...', '');
const runner = google.script.run
.withSuccessHandler(function(message) {
setStatus(message, 'success');
setBusy(false);
})
.withFailureHandler(function(error) {
setStatus(error.message || 'The Gmail action failed.', 'error');
setBusy(false);
});
if (actionName === 'applyAction') {
runner.applyAction(threadId);
}
}
How does the browser call the server?
- The function disables dashboard buttons while the request is running.
- The success handler displays the server's returned message. It re-enables the controls.
- The failure handler displays the server error. It also re-enables the controls.
- The final condition invokes applyAction(threadId) when the requested action name matches applyAction.
- Save Index.html.
- Confirm runThreadAction() sits outside renderThreads().
Each thread row now needs a button that supplies its own thread.id. The click handler passes that ID through the client bridge.
- Find the end of the threads.forEach callback inside renderThreads(threads).
- Remove the existing statement that appends only details to row.
- Replace the final row-assembly area with this code:
const actions = document.createElement('div');
actions.className = 'actions';
const actionButton = document.createElement('button');
actionButton.type = 'button';
actionButton.textContent = 'Action';
actionButton.addEventListener('click', function() {
runThreadAction('applyAction', thread.id);
});
actions.append(actionButton);
row.append(details, actions);
list.appendChild(row);
What does the row control do?
- The actions container keeps row controls separate from the sender details.
- The button uses the visible label Action.
- The click listener captures the current row's thread.id.
- The final append places the thread details beside the new control.
- Save Index.html.
- Switch back to the private test web app from earlier.
- Reload the test web app page in Microsoft Edge.
You should see an Action button beside every returned inbox thread. The rows still show their sender details plus subject information.
Missing the Action buttons?
Confirm that you saved Index.html before reloading the test URL. The test deployment uses the most recently saved code.
Check that the new row code remains inside the threads.forEach callback. It needs access to the current thread object.
Help me find why my Action buttons are missing.
✔️ Awesome, I've got everything!
Great work. Each loaded thread now has an Action control connected to its own Gmail thread ID.
ⓧ I'd like to double check the full code
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<style>
:root {
color: #202124;
font-family: Arial, sans-serif;
}
body {
background: #f6f8fc;
margin: 0;
padding: 24px;
}
main {
margin: 0 auto;
max-width: 900px;
}
header {
align-items: center;
display: flex;
gap: 16px;
justify-content: space-between;
margin-bottom: 18px;
}
h1 {
margin: 0 0 6px;
}
header p {
color: #5f6368;
margin: 0;
}
button {
background: #0b57d0;
border: 0;
border-radius: 18px;
color: #fff;
cursor: pointer;
padding: 9px 14px;
}
button:disabled {
cursor: wait;
opacity: 0.55;
}
.status {
background: #fff;
border-radius: 10px;
margin-bottom: 12px;
padding: 12px 14px;
}
.status.success {
color: #137333;
}
.status.error {
color: #b3261e;
}
.thread-list {
display: grid;
gap: 10px;
}
.thread {
align-items: center;
background: #fff;
border-radius: 12px;
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr) auto;
padding: 14px;
}
.thread.unread {
border-left: 5px solid #0b57d0;
}
.subject {
font-size: 16px;
font-weight: 700;
margin: 0 0 5px;
}
.meta {
color: #5f6368;
font-size: 13px;
margin: 2px 0;
overflow-wrap: anywhere;
}
.badge {
background: #e8f0fe;
border-radius: 12px;
color: #0b57d0;
display: inline-block;
font-size: 12px;
margin-top: 6px;
padding: 3px 8px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.empty {
background: #fff;
border-radius: 12px;
color: #5f6368;
padding: 24px;
text-align: center;
}
@media (max-width: 640px) {
.thread {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<main>
<header>
<div>
<h1>Gmail Triage Dashboard</h1>
<p>Label follow-up work, mark it read, or archive it.</p>
</div>
</header>
<div class="status" id="status" aria-live="polite">Loading your inbox...</div>
<section class="thread-list" id="threadList" aria-label="Inbox threads"></section>
</main>
<script>
function loadInbox() {
setBusy(true);
setStatus('Loading your inbox...', '');
google.script.run
.withSuccessHandler(function(threads) {
renderThreads(threads);
setStatus(threads.length + ' inbox thread(s) loaded.', 'success');
setBusy(false);
})
.withFailureHandler(function(error) {
setStatus(error.message || 'Could not load the inbox.', 'error');
setBusy(false);
})
.getInboxThreads();
}
function renderThreads(threads) {
const list = document.getElementById('threadList');
list.replaceChildren();
if (threads.length === 0) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = 'No inbox threads were returned.';
list.appendChild(empty);
return;
}
threads.forEach(function(thread) {
const row = document.createElement('article');
row.className = thread.unread ? 'thread unread' : 'thread';
const details = document.createElement('div');
const subject = document.createElement('p');
subject.className = 'subject';
subject.textContent = thread.subject;
const sender = document.createElement('p');
sender.className = 'meta';
sender.textContent = 'From: ' + thread.sender;
const date = document.createElement('p');
date.className = 'meta';
date.textContent = new Date(thread.date).toLocaleString();
details.append(subject, sender, date);
if (thread.unread) {
const badge = document.createElement('span');
badge.className = 'badge';
badge.textContent = 'Unread';
details.appendChild(badge);
}
const actions = document.createElement('div');
actions.className = 'actions';
const actionButton = document.createElement('button');
actionButton.type = 'button';
actionButton.textContent = 'Action';
actionButton.addEventListener('click', function() {
runThreadAction('applyAction', thread.id);
});
actions.append(actionButton);
row.append(details, actions);
list.appendChild(row);
});
}
function runThreadAction(actionName, threadId) {
setBusy(true);
setStatus('Updating Gmail...', '');
const runner = google.script.run
.withSuccessHandler(function(message) {
setStatus(message, 'success');
setBusy(false);
})
.withFailureHandler(function(error) {
setStatus(error.message || 'The Gmail action failed.', 'error');
setBusy(false);
});
if (actionName === 'applyAction') {
runner.applyAction(threadId);
}
}
function setStatus(message, type) {
const status = document.getElementById('status');
status.textContent = message;
status.className = type ? 'status ' + type : 'status';
}
function setBusy(isBusy) {
document.querySelectorAll('button').forEach(function(button) {
button.disabled = isBusy;
});
}
window.addEventListener('load', loadInbox);
</script>
</body>
</html>
What should this file contain?
The existing inbox loader remains unchanged. The file now adds runThreadAction() plus one Action button for each rendered thread.
Test the Action workflow
The server changes Gmail after a successful request. The current browser row keeps the data loaded before that request.
- Choose an unread test thread that you can safely label.
Before you click the button, do you expect the unread badge to update immediately after Gmail changes?
- Click Action beside your chosen thread.
You should see Action label applied and thread marked read. in the status area. The selected row still shows its old unread state.
Why is the row stale?
The server updated Gmail without replacing the thread objects already rendered in the browser. The success handler only changes the status message.
This stale row exposes the gap between server state and browser state. The next workflow closes that gap by loading fresh inbox data after a successful action.
- Open Gmail in Microsoft Edge for the intended account.
- Refresh Gmail.
- Locate the conversation you selected.
You should see the Action label on the conversation. The conversation should no longer appear unread.
Did the Gmail action fail?
Confirm that the test web app uses the same Google account as the Gmail inbox you are checking.
If the status mentions a missing thread, reload the dashboard. Select a thread that still exists in the inbox.
Help me debug the failed Gmail action.
Before you reload the dashboard, do you expect it to keep the stale unread value or fetch the current Gmail state?
- Return to the private test web app.
- Reload the test web app page.
The dashboard fetches the inbox again. The selected thread now appears without its unread indicator.
You have a working one-click follow-up workflow. Next, you will archive completed threads while keeping the dashboard synchronized with Gmail.
Archive and Refresh the Inbox
Your Action workflow now changes Gmail. The rendered row still shows the snapshot that existed before the request.
The page in Microsoft Edge needs to retrieve fresh data after each change. You will also extend the Google Apps Script project with a reversible archive operation.
In this step, get ready to:
- Add a server-side operation that archives a selected Gmail thread.
- Build synchronized controls for refreshing and archiving inbox threads.
- Confirm the dashboard matches the latest thread state in Gmail.
Add the archive operation
First, reproduce the stale-row problem from the previous step. This gives you a clear before state for the synchronization change.
- Choose a safe unread test thread in the dashboard.
Before you click the button, do you expect the dashboard row to update from Gmail without another inbox request?
- Click the existing Action button for the test thread.
You will see the success feedback. The selected row still shows its old unread state because the browser has not requested a new inbox snapshot.
- Open Gmail in another Microsoft Edge tab.
- Find the test thread you selected.
Gmail shows the thread as read with the Action label. The different states prove that the server update succeeded while the browser copy became stale.
Why does the row stay stale?
An asynchronous client-server call sends the selected thread ID to the server. The browser keeps its existing elements after the server finishes.
A fresh inbox request gives the browser the latest Gmail state. That synchronization step keeps the interface trustworthy.
- Return to the Apps Script editor from earlier.
- Select Code.gs in the editor sidebar.
- Add archiveThread(threadId) directly below applyAction(threadId) by pasting this code:
function archiveThread(threadId) {
const thread = GmailApp.getThreadById(threadId);
if (!thread) {
throw new Error('Thread not found. Refresh the dashboard and try again.');
}
thread.moveToArchive();
return 'Thread archived.';
}
What does this function do?
- The threadId parameter identifies the conversation selected in the dashboard.
- The GmailApp.getThreadById(threadId) call retrieves the matching Gmail thread.
- The missing-thread check produces useful feedback when the browser contains an outdated thread ID.
- The thread.moveToArchive() call moves the conversation out of the inbox while preserving it in Gmail.
- Save Code.gs.
The server can now archive a selected thread. The browser still needs a control that calls this function.
Seeing a code warning?
Check that archiveThread(threadId) sits outside applyAction(threadId). Each function needs its own closing brace.
Make sure the function name uses the same capitalization shown in the code block.
Ask for help with the server function.
Synchronize the dashboard controls
A manual refresh provides a clear way to request the latest inbox snapshot. The same loading function can also run automatically after a successful thread action.
- Select Index.html in the Apps Script editor sidebar.
- Insert this button immediately before the closing </header> tag:
<button class="secondary" id="refreshButton" type="button">Refresh</button>
What does this button add?
The refreshButton ID gives the script a stable reference to the control. Its button type prevents unrelated form behavior.
- Add this listener directly above the existing window load listener at the bottom of the script:
document.getElementById('refreshButton').addEventListener('click', loadInbox);
How does Refresh work?
The listener calls the existing loadInbox() function whenever you click Refresh. One loading path now supports the initial page load and manual refreshes.
- Save Index.html.
- Return to the test web app tab from earlier.
- Reload the test web app.
You will see a Refresh button beside the dashboard heading.
- Click Refresh.
The loading status returns. The dashboard retrieves the current inbox rows again.
- Return to Index.html in the Apps Script editor.
- Add this selector directly below the main button selector:
button.secondary {
background: #e8f0fe;
color: #0b57d0;
}
Why use a secondary style?
The lighter style separates data refreshes from actions that change a Gmail thread. The visual hierarchy makes the primary thread controls easier to scan.
- Save Index.html.
- Reload the test web app.
The Refresh button now has a pale background with blue text.
- Return to Index.html in the Apps Script editor.
- Add this archive style directly below the secondary button style:
button.archive {
background: #5f6368;
}
What does this style change?
Archive buttons receive a neutral gray background. This keeps their appearance distinct from the blue Action buttons.
- Find the existing Action button code inside renderThreads(threads).
- Add this block directly below the Action button click handler. Use the final actions.append(...) line shown here in place of the existing append line:
const archiveButton = document.createElement('button');
archiveButton.type = 'button';
archiveButton.className = 'archive';
archiveButton.textContent = 'Archive';
archiveButton.addEventListener('click', function() {
runThreadAction('archiveThread', thread.id);
});
actions.append(actionButton, archiveButton);
How does each row gain an Archive button?
The code creates one Archive button for each rendered thread. Its click handler passes archiveThread with that row's thread ID to the shared action runner.
The final append call places both controls in the row's action area.
- Save Index.html.
- Reload the test web app.
Every loaded row now has a blue Action button and a gray Archive button.
- Return to Index.html in the Apps Script editor.
- Replace the existing loadInbox() function with this version:
function loadInbox() {
setBusy(true);
setStatus('Loading your inbox...', '');
google.script.run
.withSuccessHandler(function(threads) {
renderThreads(threads);
setStatus(threads.length + ' inbox thread(s) loaded.', 'success');
setBusy(false);
})
.withFailureHandler(function(error) {
setStatus(error.message || 'Could not load the inbox.', 'error');
setBusy(false);
})
.getInboxThreads();
}
How does loading protect the controls?
The function disables every button before requesting inbox data. It restores the controls after either callback finishes.
The success path renders the returned rows. The failure path preserves an error message that the learner can act on.
- Replace the existing runThreadAction(actionName, threadId) function with this version:
function runThreadAction(actionName, threadId) {
setBusy(true);
setStatus('Updating Gmail...', '');
const runner = google.script.run
.withSuccessHandler(function(message) {
setStatus(message, 'success');
loadInbox();
})
.withFailureHandler(function(error) {
setStatus(error.message || 'The Gmail action failed.', 'error');
setBusy(false);
});
if (actionName === 'applyAction') {
runner.applyAction(threadId);
} else {
runner.archiveThread(threadId);
}
}
How does the shared action runner stay synchronized?
The action name chooses the matching server function. Both paths send the selected thread ID through the same success and failure handlers.
A successful response calls loadInbox() immediately. The next render uses fresh Gmail data instead of the stale browser snapshot.
- Save Index.html.
- Return to the test web app.
- Reload the page.
You will see Refresh, Action, and Archive controls. The buttons become unavailable while an inbox request is running.
Missing a control or seeing a failed request?
Check that refreshButton matches the ID used by the event listener. A different spelling prevents the Refresh control from calling loadInbox().
Check that the Archive click handler passes archiveThread to runThreadAction().
Ask for help with the dashboard controls.
✔️ Awesome, I've got everything!
Great. Double check that you saved both project files before testing the complete workflow.
ⓧ I'd like to double check the full code
Compare Code.gs with this complete server file.
function doGet() {
return HtmlService.createHtmlOutputFromFile('Index')
.setTitle('Gmail Triage Dashboard');
}
function getInboxThreads() {
const threads = GmailApp.getInboxThreads(0, 10);
return threads.map(function(thread) {
const messages = thread.getMessages();
const latestMessage = messages[messages.length - 1];
return {
id: thread.getId(),
subject: thread.getFirstMessageSubject() || '(No subject)',
sender: latestMessage.getFrom(),
date: latestMessage.getDate().toISOString(),
unread: thread.isUnread(),
};
});
}
function applyAction(threadId) {
const thread = GmailApp.getThreadById(threadId);
if (!thread) {
throw new Error('Thread not found. Refresh the dashboard and try again.');
}
const label = getOrCreateActionLabel_();
label.addToThread(thread);
thread.markRead();
return 'Action label applied and thread marked read.';
}
function archiveThread(threadId) {
const thread = GmailApp.getThreadById(threadId);
if (!thread) {
throw new Error('Thread not found. Refresh the dashboard and try again.');
}
thread.moveToArchive();
return 'Thread archived.';
}
function getOrCreateActionLabel_() {
const labels = GmailApp.getUserLabels();
const actionLabel = labels.find(function(label) {
return label.getName() === 'Action';
});
return actionLabel || GmailApp.createLabel('Action');
}
This complete server file contains the inbox reader and both thread actions. The private label helper remains available only to server-side functions.
Compare Index.html with this complete dashboard file.
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<style>
:root {
color: #202124;
font-family: Arial, sans-serif;
}
body {
background: #f6f8fc;
margin: 0;
padding: 24px;
}
main {
margin: 0 auto;
max-width: 900px;
}
header {
align-items: center;
display: flex;
gap: 16px;
justify-content: space-between;
margin-bottom: 18px;
}
h1 {
margin: 0 0 6px;
}
header p {
color: #5f6368;
margin: 0;
}
button {
background: #0b57d0;
border: 0;
border-radius: 18px;
color: #fff;
cursor: pointer;
padding: 9px 14px;
}
button.secondary {
background: #e8f0fe;
color: #0b57d0;
}
button.archive {
background: #5f6368;
}
button:disabled {
cursor: wait;
opacity: 0.55;
}
.status {
background: #fff;
border-radius: 10px;
margin-bottom: 12px;
padding: 12px 14px;
}
.status.success {
color: #137333;
}
.status.error {
color: #b3261e;
}
.thread-list {
display: grid;
gap: 10px;
}
.thread {
align-items: center;
background: #fff;
border-radius: 12px;
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr) auto;
padding: 14px;
}
.thread.unread {
border-left: 5px solid #0b57d0;
}
.subject {
font-size: 16px;
font-weight: 700;
margin: 0 0 5px;
}
.meta {
color: #5f6368;
font-size: 13px;
margin: 2px 0;
overflow-wrap: anywhere;
}
.badge {
background: #e8f0fe;
border-radius: 12px;
color: #0b57d0;
display: inline-block;
font-size: 12px;
margin-top: 6px;
padding: 3px 8px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.empty {
background: #fff;
border-radius: 12px;
color: #5f6368;
padding: 24px;
text-align: center;
}
@media (max-width: 640px) {
.thread {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<main>
<header>
<div>
<h1>Gmail Triage Dashboard</h1>
<p>Label follow-up work, mark it read, or archive it.</p>
</div>
<button class="secondary" id="refreshButton" type="button">Refresh</button>
</header>
<div class="status" id="status" aria-live="polite">Loading your inbox...</div>
<section class="thread-list" id="threadList" aria-label="Inbox threads"></section>
</main>
<script>
function loadInbox() {
setBusy(true);
setStatus('Loading your inbox...', '');
google.script.run
.withSuccessHandler(function(threads) {
renderThreads(threads);
setStatus(threads.length + ' inbox thread(s) loaded.', 'success');
setBusy(false);
})
.withFailureHandler(function(error) {
setStatus(error.message || 'Could not load the inbox.', 'error');
setBusy(false);
})
.getInboxThreads();
}
function renderThreads(threads) {
const list = document.getElementById('threadList');
list.replaceChildren();
if (threads.length === 0) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = 'No inbox threads were returned.';
list.appendChild(empty);
return;
}
threads.forEach(function(thread) {
const row = document.createElement('article');
row.className = thread.unread ? 'thread unread' : 'thread';
const details = document.createElement('div');
const subject = document.createElement('p');
subject.className = 'subject';
subject.textContent = thread.subject;
const sender = document.createElement('p');
sender.className = 'meta';
sender.textContent = 'From: ' + thread.sender;
const date = document.createElement('p');
date.className = 'meta';
date.textContent = new Date(thread.date).toLocaleString();
details.append(subject, sender, date);
if (thread.unread) {
const badge = document.createElement('span');
badge.className = 'badge';
badge.textContent = 'Unread';
details.appendChild(badge);
}
const actions = document.createElement('div');
actions.className = 'actions';
const actionButton = document.createElement('button');
actionButton.type = 'button';
actionButton.textContent = 'Action';
actionButton.addEventListener('click', function() {
runThreadAction('applyAction', thread.id);
});
const archiveButton = document.createElement('button');
archiveButton.type = 'button';
archiveButton.className = 'archive';
archiveButton.textContent = 'Archive';
archiveButton.addEventListener('click', function() {
runThreadAction('archiveThread', thread.id);
});
actions.append(actionButton, archiveButton);
row.append(details, actions);
list.appendChild(row);
});
}
function runThreadAction(actionName, threadId) {
setBusy(true);
setStatus('Updating Gmail...', '');
const runner = google.script.run
.withSuccessHandler(function(message) {
setStatus(message, 'success');
loadInbox();
})
.withFailureHandler(function(error) {
setStatus(error.message || 'The Gmail action failed.', 'error');
setBusy(false);
});
if (actionName === 'applyAction') {
runner.applyAction(threadId);
} else {
runner.archiveThread(threadId);
}
}
function setStatus(message, type) {
const status = document.getElementById('status');
status.textContent = message;
status.className = type ? 'status ' + type : 'status';
}
function setBusy(isBusy) {
document.querySelectorAll('button').forEach(function(button) {
button.disabled = isBusy;
});
}
document.getElementById('refreshButton').addEventListener('click', loadInbox);
window.addEventListener('load', loadInbox);
</script>
</body>
</html>
This complete page safely renders Gmail-derived strings with textContent. It also reloads the inbox after successful Action and Archive operations.
Test the complete inbox loop
The controls now share one request cycle. A safe test thread lets you prove that the browser and Gmail finish in the same state.
- Choose an inbox thread that you can safely archive.
Before you click Archive, do you expect the processed row to remain visible or disappear after the inbox reloads?
- Click Archive on the selected row.
You will see the update status while the controls are unavailable. The dashboard reloads from Gmail and removes the archived row from the inbox list.
- Switch to the Gmail tab.
- Search for the archived test thread.
The conversation remains available in Gmail. It no longer appears in the inbox.
- Return to the dashboard tab.
- Click Action on another safe unread thread.
The dashboard reloads after the success response. The selected row remains visible without its unread badge.
- Switch to Gmail.
- Find the second test thread.
The second thread shows the Action label. Its unread state now matches the refreshed dashboard row.
- Return to the dashboard.
- Click Refresh for one final inbox request.
You will see the loading state followed by the current inbox thread count. The dashboard and Gmail now show the same results.
You closed the synchronization gap. Your dashboard now labels, marks read, archives, and refreshes against live Gmail data.
Secret mission
Filter the Loaded Inbox
Add an instant search field that filters the loaded inbox by sender or subject. The dashboard updates from its stored inbox snapshot without making another Gmail request.
Clean Up Your Resources
Clean Up Your Resources
This project adds no paid service. Consumer Google Apps Script quotas apply only when you use the dashboard.
Choose the cleanup option that fits your plans for the dashboard.
Resources you used:
- One standalone Gmail Triage Dashboard project stored in Google Drive. Its private test web app deployment belongs to this project.
- One Gmail label named Action, if the dashboard created it.
- Any test conversations that you marked read or moved out of the inbox during testing.
Keep everything running
No action needed. Choose this if you plan to keep using the dashboard for personal inbox triage.
- Keep the Gmail Triage Dashboard project in Google Drive.
- Continue using the existing private test URL in Microsoft Edge.
- Keep the Action label for conversations that need follow-up.
- Use the dashboard only with the intended Google account.
The project has no background trigger. It performs no work while the test web app is idle.
Pause - I'll come back to this later
Leave the project saved while you take a break. The dashboard uses no background trigger while it sits idle.
- Close the dashboard test tab in Microsoft Edge.
- Leave the Gmail Triage Dashboard project in Google Drive.
- Keep the existing Action label if you want to preserve your follow-up workflow.
- Return later through the existing private test URL.
Your code remains available. Gmail changes only when you use a dashboard control.
Delete - I don't want to use this again
Mailbox cleanup deserves a quick check because the dashboard changed real conversations. Restore any test conversations before removing the project.
- Return to Gmail in the signed-in Microsoft Edge window.
- Locate each conversation that you archived during testing.
- Use Gmail's inbox move control to restore each conversation.
- Mark a restored conversation unread only if you know it was unread before testing.
- Refresh Gmail.
Your restored test conversations should now appear in the inbox. Any unread state you restored should also be visible.
The Action label may have existed before this project. Keep it if it supports another mail workflow.
- Locate Action in Gmail's label list.
- Open the label's options menu.
- Remove the label if you no longer use it.
- Refresh Gmail.
The Action label should no longer appear if you chose to remove it.
Permanent project deletion removes the dashboard code. Your Google account remains available.
- Go to Google Drive in the signed-in Microsoft Edge window.
- Find the Gmail Triage Dashboard project in the file list.
- Move the project to the trash from its file-list context menu.
- Switch to the trash folder.
- Permanently delete the Gmail Triage Dashboard project from its context menu.
- Refresh the trash folder.
You should no longer see the project in Google Drive. Its private test web app deployment is removed with it.
Nice Work!
Nice Work!
You did it! You built a private Gmail Triage Dashboard with Google Apps Script that runs in Microsoft Edge.
You've learned how to:
- Built a live inbox dashboard that retrieves up to 10 threads. It renders Gmail data safely with textContent.
- Created an Action workflow that applies the reusable Action label. The same click marks the selected thread as read.
- Added Archive and Refresh controls that synchronize the dashboard with Gmail after each change. Controls stay disabled while a request runs.
- Completed the Secret Mission by adding instant sender or subject filtering. The matching count updates without another Gmail request.
Ready to quiz yourself?