Build Email Snooze Chrome Extension (2026)

Email snooze functionality has become essential for managing inbox overwhelm. Chrome extensions that implement email snooze scheduling allow users to temporarily remove emails from their inbox and have them reappear at a specified future time. This guide covers the implementation patterns, APIs, and practical considerations for developers building this type of extension.

How Email Snooze Extensions Work

Chrome extensions for email snooze scheduling typically operate by interacting with email provider APIs. The extension intercepts email messages, stores the snooze metadata on a backend or locally, and then uses scheduled triggers to move or relabel those emails back to the inbox at the designated time.

The core architecture involves three main components:

  1. Content Script - Runs in the context of the email provider’s web interface, identifying emails and adding snooze UI elements
  2. Background Service Worker - Handles scheduling logic and API calls
  3. Storage Layer - Maintains snooze state using chrome.storage or a backend database

Architecture Decision: Local vs. Backend Storage

Before writing a single line of code, decide where your snooze state lives. This choice affects reliability, privacy, and the complexity of your extension.

Approach Pros Cons
chrome.storage.local No backend needed, works offline, simple Snooze lost if user clears extension data, no cross-device sync
chrome.storage.sync Cross-device sync via Google account 100KB total quota, 8KB per item limit
Backend database Full control, cross-device, recoverable Requires auth, server cost, GDPR surface area
Provider labels (Gmail) Snooze survives browser uninstall Slower, requires API scope, pollutes label list

For a personal productivity tool, chrome.storage.local with a fallback to provider labels is often the right balance. For a commercial product, a lightweight backend with OAuth gives users the reliability they expect.

Manifest V3 Implementation

Modern Chrome extensions must use Manifest V3, which introduces several changes from V2. Here’s a basic manifest structure for an email snooze extension:

{
 "manifest_version": 3,
 "name": "Email Snooze Scheduler",
 "version": "1.0",
 "permissions": [
 "storage",
 "alarms",
 "activeTab",
 "scripting"
 ],
 "host_permissions": [
 "https://mail.google.com/*",
 "https://outlook.office.com/*"
 ],
 "background": {
 "service_worker": "background.js"
 },
 "content_scripts": [{
 "matches": [
 "https://mail.google.com/*",
 "https://outlook.office.com/*"
 ],
 "js": ["content.js"]
 }]
}

The key Manifest V3 constraint to understand is that background pages are gone. you have a service worker instead. Service workers are ephemeral. They spin up to handle an event and then terminate. This means you cannot use global variables to hold state between alarm firings. Everything that needs to survive must go into chrome.storage.

Core Implementation Patterns

Storing Snooze Data

The chrome.storage API provides persistent storage that works across browser sessions. For email snooze extensions, you’ll need to store the email ID, original label, and scheduled return time.

// content.js - Snooze an email
function snoozeEmail(emailId, snoozeUntil) {
 const snoozeEntry = {
 emailId: emailId,
 originalLabels: ['INBOX'], // Capture current labels
 snoozeUntil: snoozeUntil,
 provider: 'gmail' // or 'outlook', etc.
 };
 chrome.storage.local.get(['snoozedEmails'], (result) => {
 const snoozedEmails = result.snoozedEmails || [];
 snoozedEmails.push(snoozeEntry);
 chrome.storage.local.set({ snoozedEmails });
 });
 // Notify background script to schedule the alarm
 chrome.runtime.sendMessage({
 action: 'scheduleSnooze',
 emailId: emailId,
 snoozeUntil: snoozeUntil
 });
}

One subtlety: chrome.storage.local.get is asynchronous, and the callback pattern above can cause race conditions if multiple snooze actions fire close together. Prefer the Promise-based API with async/await for cleaner sequencing:

// Safer async version
async function snoozeEmail(emailId, snoozeUntil) {
 const snoozeEntry = {
 emailId,
 originalLabels: ['INBOX'],
 snoozeUntil,
 provider: 'gmail',
 snoozedAt: Date.now()
 };
 const result = await chrome.storage.local.get(['snoozedEmails']);
 const snoozedEmails = result.snoozedEmails || [];
 // Deduplicate: remove any existing snooze for this email before adding
 const filtered = snoozedEmails.filter(e => e.emailId !== emailId);
 filtered.push(snoozeEntry);
 await chrome.storage.local.set({ snoozedEmails: filtered });
 chrome.runtime.sendMessage({
 action: 'scheduleSnooze',
 emailId,
 snoozeUntil
 });
}

Scheduling with Chrome Alarms

Chrome’s alarms API allows you to schedule future events reliably, even when the extension isn’t actively running. The alarms API is the correct tool here. do not use setTimeout in a service worker, because the worker may terminate before the timeout fires.

// background.js
chrome.alarms.onAlarm.addListener((alarm) => {
 if (alarm.name.startsWith('snooze-')) {
 const emailId = alarm.name.replace('snooze-', '');
 processSnoozeReturn(emailId);
 }
});
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
 if (message.action === 'scheduleSnooze') {
 const delay = message.snoozeUntil - Date.now();
 if (delay > 0) {
 chrome.alarms.create(`snooze-${message.emailId}`, {
 delayInMinutes: Math.ceil(delay / 60000),
 periodInMinutes: null
 });
 }
 }
});
async function processSnoozeReturn(emailId) {
 // Retrieve stored email metadata
 const result = await chrome.storage.local.get(['snoozedEmails']);
 const snoozedEmails = result.snoozedEmails || [];
 const entry = snoozedEmails.find(e => e.emailId === emailId);
 if (entry) {
 // Use provider API to move email back to inbox
 await moveEmailToInbox(entry);
 // Clean up storage
 const updated = snoozedEmails.filter(e => e.emailId !== emailId);
 await chrome.storage.local.set({ snoozedEmails: updated });
 }
}

One important limitation: Chrome alarms have a minimum granularity of approximately one minute. If you need sub-minute precision (unusual for email snooze), you cannot achieve it with the alarms API alone. For typical snooze use cases. later today, tomorrow morning. one-minute granularity is perfectly acceptable.

Handling Browser Restarts

When Chrome restarts, existing alarms survive (they are persisted by the browser). However, if your background service worker starts fresh after a restart, it needs to re-register its alarm listener. Because listeners are registered at service worker startup, the chrome.alarms.onAlarm.addListener call at the top of your background.js handles this automatically.

What does not survive is any in-memory state. Always reload from chrome.storage when the service worker activates:

// background.js - startup check for missed snoozes
chrome.runtime.onStartup.addListener(async () => {
 const result = await chrome.storage.local.get(['snoozedEmails']);
 const snoozedEmails = result.snoozedEmails || [];
 const now = Date.now();
 for (const entry of snoozedEmails) {
 if (entry.snoozeUntil <= now) {
 // Alarm fired while Chrome was closed. process immediately
 await processSnoozeReturn(entry.emailId);
 }
 }
});

This startup handler catches any snoozes that matured while the browser was closed, ensuring users see their emails when they open Chrome the next morning.

Provider API Integration

Gmail API Integration

For Gmail integration, you’ll need to use the Gmail API to modify email labels. The typical snooze flow is: remove the INBOX label (archiving the email), optionally add a custom SNOOZED label, and then on wake, add INBOX back and remove the SNOOZED label.

async function moveEmailToInbox(snoozeEntry) {
 const { accessToken } = await getAccessToken();
 // Remove the snooze label and restore INBOX
 await fetch(
 `https://gmail.googleapis.com/gmail/v1/users/me/messages/${snoozeEntry.emailId}/modify`,
 {
 method: 'POST',
 headers: {
 'Authorization': `Bearer ${accessToken}`,
 'Content-Type': 'application/json'
 },
 body: JSON.stringify({
 removeLabelIds: ['SNOOZE_LABEL_ID'],
 addLabelIds: ['INBOX', 'UNREAD']
 })
 }
 );
}

To snooze an email, perform the inverse. remove INBOX and add your custom SNOOZED label:

async function archiveForSnooze(emailId, snoozeLabelId) {
 const { accessToken } = await getAccessToken();
 await fetch(
 `https://gmail.googleapis.com/gmail/v1/users/me/messages/${emailId}/modify`,
 {
 method: 'POST',
 headers: {
 'Authorization': `Bearer ${accessToken}`,
 'Content-Type': 'application/json'
 },
 body: JSON.stringify({
 removeLabelIds: ['INBOX'],
 addLabelIds: [snoozeLabelId]
 })
 }
 );
}
async function ensureSnoozeLabelExists() {
 const { accessToken } = await getAccessToken();
 // List existing labels and find or create the snooze label
 const res = await fetch(
 'https://gmail.googleapis.com/gmail/v1/users/me/labels',
 { headers: { 'Authorization': `Bearer ${accessToken}` } }
 );
 const data = await res.json();
 const existing = data.labels.find(l => l.name === 'Snoozed');
 if (existing) return existing.id;
 const create = await fetch(
 'https://gmail.googleapis.com/gmail/v1/users/me/labels',
 {
 method: 'POST',
 headers: {
 'Authorization': `Bearer ${accessToken}`,
 'Content-Type': 'application/json'
 },
 body: JSON.stringify({
 name: 'Snoozed',
 labelListVisibility: 'labelHide',
 messageListVisibility: 'hide'
 })
 }
 );
 const created = await create.json();
 return created.id;
}

Outlook API Integration

Microsoft Graph API handles Outlook.com and Office 365. The pattern mirrors Gmail but uses folder moves instead of label modifications:

async function moveOutlookEmailToInbox(snoozeEntry) {
 const { accessToken } = await getAccessToken();
 // Move message to inbox folder
 await fetch(
 `https://graph.microsoft.com/v1.0/me/messages/${snoozeEntry.emailId}/move`,
 {
 method: 'POST',
 headers: {
 'Authorization': `Bearer ${accessToken}`,
 'Content-Type': 'application/json'
 },
 body: JSON.stringify({
 destinationId: 'inbox'
 })
 }
 );
}
async function archiveOutlookEmailForSnooze(emailId) {
 const { accessToken } = await getAccessToken();
 // Move to a dedicated snooze folder
 const folderId = await ensureOutlookSnoozeFolderExists(accessToken);
 await fetch(
 `https://graph.microsoft.com/v1.0/me/messages/${emailId}/move`,
 {
 method: 'POST',
 headers: {
 'Authorization': `Bearer ${accessToken}`,
 'Content-Type': 'application/json'
 },
 body: JSON.stringify({ destinationId: folderId })
 }
 );
}

Provider API Comparison

Feature Gmail API Microsoft Graph
Auth flow OAuth 2.0 (Google Identity) OAuth 2.0 (MSAL)
Snooze mechanism Label add/remove Folder move
Required scope gmail.modify Mail.ReadWrite
Rate limit 250 quota units/user/second 10,000 requests per 10 min
Batch operations Yes (batch endpoint) Yes (batch endpoint)
Webhook support Push notifications (Cloud Pub/Sub) Microsoft Graph subscriptions

User Interface Considerations

The snooze UI typically appears as a dropdown or popup when users hover over or select an email. Common preset options include:

  • Later Today - 6 PM or custom time
  • Tomorrow Morning - 9 AM next day
  • Tomorrow Evening - 6 PM next day
  • This Weekend - Saturday 9 AM
  • Next Week - Monday 9 AM
  • Custom - User-specified date and time

Implement custom time selection using a date-time picker:

function showCustomSnoozeDialog() {
 const dialog = document.createElement('div');
 dialog.innerHTML = `
 <div class="snooze-dialog">
 <h3>Schedule Snooze</h3>
 <input type="datetime-local" id="snooze-datetime">
 <button id="confirm-snooze">Snooze</button>
 <button id="cancel-snooze">Cancel</button>
 </div>
 `;
 document.body.appendChild(dialog);
 document.getElementById('confirm-snooze').addEventListener('click', () => {
 const datetime = document.getElementById('snooze-datetime').value;
 const snoozeUntil = new Date(datetime).getTime();
 snoozeEmail(currentEmailId, snoozeUntil);
 dialog.remove();
 });
 document.getElementById('cancel-snooze').addEventListener('click', () => {
 dialog.remove();
 });
}

Pre-populate the datetime input with a reasonable default to reduce friction:

function getDefaultSnoozeTime() {
 const now = new Date();
 const tomorrow = new Date(now);
 tomorrow.setDate(tomorrow.getDate() + 1);
 tomorrow.setHours(9, 0, 0, 0);
 // Format as YYYY-MM-DDTHH:MM for datetime-local input
 const pad = n => String(n).padStart(2, '0');
 return `${tomorrow.getFullYear()}-${pad(tomorrow.getMonth()+1)}-${pad(tomorrow.getDate())}T09:00`;
}

Injecting UI into Gmail

Gmail’s DOM changes frequently, which is the main fragility point for content scripts that inject UI. Target stable data attributes rather than CSS class names, which Gmail minifies and rotates:

// Prefer data attributes and role selectors over class names
const emailRows = document.querySelectorAll('[data-legacy-message-id]');
emailRows.forEach(row => {
 if (!row.querySelector('.snooze-btn')) {
 const btn = document.createElement('button');
 btn.className = 'snooze-btn';
 btn.textContent = 'Snooze';
 btn.addEventListener('click', (e) => {
 e.stopPropagation();
 const emailId = row.dataset.legacyMessageId;
 showSnoozeMenu(emailId, btn);
 });
 row.appendChild(btn);
 }
});

Use a MutationObserver to handle Gmail’s virtual rendering, which adds and removes rows dynamically as you scroll:

const observer = new MutationObserver(() => {
 injectSnoozeButtons();
});
observer.observe(document.body, {
 childList: true,
 subtree: true
});

Security and Privacy

Email snooze extensions handle sensitive data, so implement these security practices:

  1. Minimal Permissions - Request only the scopes needed for email access
  2. Secure Token Storage - Use chrome.storage.session for access tokens when possible
  3. Content Security Policy - Restrict script sources in your manifest
  4. OAuth 2.0 Flow - Never store user passwords directly
{
 "oauth2": {
 "client_id": "YOUR_CLIENT_ID",
 "scopes": [
 "https://www.googleapis.com/auth/gmail.modify",
 "https://www.googleapis.com/auth/gmail.readonly"
 ]
 }
}

Use chrome.storage.session for access tokens. session storage clears when the browser closes, limiting the window of exposure if a machine is stolen:

// Store token in session storage (cleared on browser close)
async function cacheAccessToken(token, expiresIn) {
 await chrome.storage.session.set({
 accessToken: token,
 tokenExpiry: Date.now() + (expiresIn * 1000)
 });
}
async function getAccessToken() {
 const result = await chrome.storage.session.get(['accessToken', 'tokenExpiry']);
 if (result.accessToken && result.tokenExpiry > Date.now()) {
 return { accessToken: result.accessToken };
 }
 // Token missing or expired. fetch a new one
 return await refreshAccessToken();
}

Also add a Content Security Policy to your manifest to prevent injected scripts from loading external resources:

{
 "content_security_policy": {
 "extension_pages": "script-src 'self'; object-src 'self'"
 }
}

Testing Your Snooze Extension

Unit Testing Alarm Logic

Test the alarm scheduling logic in isolation using Jest and a mock Chrome API:

// Mock chrome.alarms for unit tests
global.chrome = {
 alarms: {
 create: jest.fn(),
 onAlarm: { addListener: jest.fn() }
 },
 storage: {
 local: {
 get: jest.fn(),
 set: jest.fn()
 }
 },
 runtime: {
 sendMessage: jest.fn(),
 onMessage: { addListener: jest.fn() }
 }
};
test('scheduleSnooze creates alarm with correct delay', () => {
 const emailId = 'abc123';
 const snoozeUntil = Date.now() + 3600000; // 1 hour from now
 scheduleSnoozeAlarm(emailId, snoozeUntil);
 expect(chrome.alarms.create).toHaveBeenCalledWith(
 `snooze-${emailId}`,
 expect.objectContaining({ delayInMinutes: 60 })
 );
});

End-to-End Testing Considerations

For end-to-end tests, use Puppeteer or Playwright with the --load-extension flag to load your unpacked extension. Test the full snooze and wake cycle against a real Gmail sandbox account with test emails you control.

Building a Custom Solution

For developers building their own snooze functionality, start with the fundamental patterns shown here and expand based on your specific requirements. Consider these enhancements as your extension matures:

Snooze categories. Let users tag snoozes as “follow-up”, “waiting”, or “read later”. Surface these tags in a dedicated sidebar panel so users can see all snoozed emails at a glance without waiting for them to reappear.

Smart snooze suggestions. Analyze email content for date mentions and suggest snooze times automatically. An email saying “let me know by Friday” could trigger a Thursday afternoon suggestion.

Notification before wake. Use the Web Notifications API to alert users five minutes before a snoozed email returns, giving them context before it appears in their inbox.

Bulk snooze. Let users select multiple emails and snooze them to the same time in a single action. This is especially useful for mailing list digests or newsletters.

// Bulk snooze example
async function bulkSnooze(emailIds, snoozeUntil) {
 for (const emailId of emailIds) {
 await snoozeEmail(emailId, snoozeUntil);
 }
}

The key is maintaining reliable scheduling even when the browser is closed, which requires combining chrome.alarms with a startup handler that catches missed firings. Pair that with a lightweight OAuth flow and the provider API patterns shown here, and you have a production-grade snooze extension that handles the full lifecycle from snooze to wake without data loss.


Try it: Estimate your monthly spend with our Cost Calculator.

I'm a solo developer in Vietnam. 50K Chrome extension users. $500K+ on Upwork. 5 Claude Max subscriptions running agent fleets in parallel. These are my actual CLAUDE.md templates, orchestration configs, and prompts. Not a course. Not theory. The files I copy into every project before I write a line of code. **[See what's inside →](https://zovo.one/lifetime?utm_source=ccg&utm_medium=cta-default&utm_campaign=chrome-extension-email-snooze-scheduler)** $99 once. Free forever. 47/500 founding spots left.

Related Reading

Built by theluckystrike. More at zovo.one

Configure permissions → Build your settings with our Permission Configurator.