Skip to content

Instantly share code, notes, and snippets.

@spencerthayer
Last active May 2, 2025 16:06
Show Gist options
  • Save spencerthayer/1a7c0e3f1e7487a2f051dd038c535d7e to your computer and use it in GitHub Desktop.
Save spencerthayer/1a7c0e3f1e7487a2f051dd038c535d7e to your computer and use it in GitHub Desktop.
Google Calendar Auto‑Decline Script (v1.9) — Auto‑decline conflicting Google Calendar invites with configurable look‑aheads, overlap thresholds, ignore‑list, batch‑modes, and notifications. It even has a dry‑run test mode.
/**
* Google Calendar Auto-Decline Conflicting Invites Script
* VERSION 1.9 - Added organizer notification when invites are auto-declined.
*
* Automatically declines incoming Google Calendar invitations that conflict
* with existing events where the user has not declined ("No").
* Allows ignoring events based on their title.
* Can be run directly or via a time-driven trigger (e.g., every 15/30 mins).
*/
// --- Configuration ---
// Modify these settings to customize the script's behavior.
const CONFIG = {
// How many days into the future to check for new invites.
lookAheadDays: 7,
// Minimum overlap time (in minutes) required to trigger an auto-decline.
// Set to 0 or 1 to decline any overlap.
minOverlapMinutes: 1,
// Send an email to yourself when an event is auto-declined? (true/false)
// Recommended since comments cannot be added directly to the event.
sendEmailNotifications: true,
// Send an email notification if the script encounters a major error? (true/false)
sendErrorNotifications: true,
// Send an email to the event organizer when auto-declining? (true/false)
notifyOrganizer: true,
// Process the calendar in smaller chunks (batches)? Recommended for very busy calendars
// or long lookAheadDays to avoid exceeding script runtime limits. (true/false)
processInBatches: false,
// If processInBatches is true, how many days to process in each batch.
batchSizeInDays: 3,
// Titles of events that shouldn't block invites (case-insensitive).
// Example: ["Home", "Lunch", "Commute", "Focus Time"]
// Events with these exact titles will be ignored when checking for conflicts.
ignoreEvents: ["Home", "AM All Hands", "PM All Hands", "Afternoon Check-in", "Fireside Chat", "Studio Monthly"],
// **SET TO true FOR TESTING ONLY**. If true, the script will log what it *would* decline
// but will NOT actually decline any events or send decline notifications. (true/false)
dryRun: false
};
// --- End Configuration ---
/**
* Main function to initiate the auto-decline process.
* This is the function you should select to run manually or set up in a trigger.
*/
function autoDeclineConflictingInvites() {
// Use a copy of the config for this specific run
const scriptConfig = JSON.parse(JSON.stringify(CONFIG));
if (scriptConfig.dryRun) {
Logger.log('🧪 DRY RUN MODE ENABLED: No events will be modified or declined.');
}
try {
const calendar = CalendarApp.getDefaultCalendar();
const now = new Date();
const endDate = new Date(now.getTime());
endDate.setDate(now.getDate() + scriptConfig.lookAheadDays);
Logger.log(`Checking for conflicting invites from ${now.toLocaleString()} to ${endDate.toLocaleString()}`);
let totalDeclined = 0;
if (scriptConfig.processInBatches) {
totalDeclined = processBatches(calendar, now, endDate, scriptConfig);
} else {
totalDeclined = processDateRange(calendar, now, endDate, scriptConfig);
}
Logger.log(`✅ Script finished. Processed invites up to ${endDate.toLocaleString()}. Total auto-declined: ${totalDeclined} (Dry Run: ${scriptConfig.dryRun})`);
} catch (err) {
Logger.log(`SCRIPT FAILED: An error occurred during execution: ${err.message}\n${err.stack}`);
if (scriptConfig.sendErrorNotifications) {
try {
MailApp.sendEmail(
Session.getActiveUser().getEmail(),
'⚠️ Calendar Auto-Decline Script Error',
`The Google Calendar auto-decline script encountered an error:\n\n${err.message}\n\nStack Trace:\n${err.stack}\n\nPlease check the script logs for more details.`
);
} catch (mailErr) {
Logger.log(`🚨 Failed to send error notification email: ${mailErr.message}`);
}
}
}
}
/**
* Processes the calendar check in batches (e.g., week by week).
* Useful for avoiding script execution time limits on large calendars.
* @param {CalendarApp.Calendar} calendar The CalendarApp object for the default calendar.
* @param {Date} startDate The overall start date for checking.
* @param {Date} endDate The overall end date for checking.
* @param {Object} config The configuration settings for this run.
* @return {number} The total number of events declined across all batches.
*/
function processBatches(calendar, startDate, endDate, config) {
let batchStart = new Date(startDate.getTime());
let totalDeclinedCount = 0;
// Ensure batchSizeInDays is positive to prevent infinite loops
const batchDays = Math.max(1, config.batchSizeInDays);
const batchSizeMillis = batchDays * 24 * 60 * 60 * 1000;
Logger.log(`Batch processing enabled (Batch size: ${batchDays} days).`);
while (batchStart < endDate) {
let batchEnd = new Date(batchStart.getTime() + batchSizeMillis);
if (batchEnd > endDate) {
batchEnd = new Date(endDate.getTime()); // Don't go past the overall end date
}
// Prevent processing if batchStart somehow becomes >= batchEnd
if (batchStart >= batchEnd) {
Logger.log(`Skipping zero-duration batch from ${batchStart.toLocaleDateString()}.`);
break;
}
Logger.log(`--- Processing Batch: ${batchStart.toLocaleDateString()} to ${batchEnd.toLocaleDateString()} ---`);
totalDeclinedCount += processDateRange(calendar, batchStart, batchEnd, config);
// Move to the next batch start date
batchStart = new Date(batchEnd.getTime());
}
Logger.log(`--- Batch processing complete. Total declined: ${totalDeclinedCount} ---`);
return totalDeclinedCount;
}
/**
* Processes a specific date range, finding and potentially declining conflicting invites.
* @param {CalendarApp.Calendar} calendar The CalendarApp object for the default calendar.
* @param {Date} startDate The start date for this specific range check.
* @param {Date} endDate The end date for this specific range check.
* @param {Object} config The configuration settings for this run.
* @return {number} The number of events declined in this date range.
*/
function processDateRange(calendar, startDate, endDate, config) {
let declinedCount = 0;
try {
// 1. Get all events in the specified date range.
const eventsInRange = calendar.getEvents(startDate, endDate);
// 2. Filter these events to find only those where the user's status is INVITED.
const potentialInvites = eventsInRange.filter(event => {
try {
// Ensure the event object is valid and has getMyStatus method
if (event && typeof event.getMyStatus === 'function') {
return event.getMyStatus() === CalendarApp.GuestStatus.INVITED;
}
return false;
} catch (e) {
// Log error getting status, but don't stop the script
Logger.log(`⚠️ Error getting status for an event (ID potentially ${event ? event.getId() : 'unknown'}): ${e.message}. Skipping this event.`);
return false;
}
});
if (potentialInvites.length === 0) {
Logger.log(`No pending invites found in range ${startDate.toLocaleDateString()} to ${endDate.toLocaleDateString()}.`);
return 0; // No invites to process in this range
}
Logger.log(`Found ${potentialInvites.length} pending invite(s) in range ${startDate.toLocaleDateString()} to ${endDate.toLocaleDateString()}. Checking for conflicts...`);
// 3. Iterate through each potential new invite.
potentialInvites.forEach(invite => {
let inviteTitle = '(Untitled Event)'; // Default title
let inviteId = 'unknown';
try {
inviteId = invite.getId(); // Get ID early for logging
inviteTitle = invite.getTitle() || inviteTitle; // Use fetched title or default
const inviteStart = invite.getStartTime();
const inviteEnd = invite.getEndTime();
// Skip if event data is incomplete (e.g., missing times)
if (!inviteStart || !inviteEnd) {
Logger.log(`⚠️ Skipping invite "${inviteTitle}" (ID: ${inviteId}) due to missing start/end time.`);
return; // continue to next invite in forEach
}
Logger.log(`🔎 Checking invite: "${inviteTitle}" (${inviteStart.toLocaleString()} - ${inviteEnd.toLocaleString()}) ID: ${inviteId}`);
// 4. Find all events that overlap with this invite's time.
const overlappingEvents = calendar.getEvents(inviteStart, inviteEnd);
let conflictFound = false;
let conflictingEventDetails = ""; // Store details of the event causing the conflict
// 5. Check each overlapping event for a genuine conflict.
for (const existingEvent of overlappingEvents) {
const existingEventId = existingEvent.getId();
// Skip checking the invite against itself.
if (existingEventId === inviteId) {
continue;
}
let existingTitle = '(Untitled Event)'; // Default title for existing event
try {
existingTitle = existingEvent.getTitle() || existingTitle;
// --- Check: Ignore based on Event Title ---
if (config.ignoreEvents && config.ignoreEvents.length > 0 && existingTitle) {
// Case-insensitive check against the ignore list
if (config.ignoreEvents.some(ignoredEvent =>
existingTitle.toLowerCase() === ignoredEvent.toLowerCase())) {
Logger.log(`--> Ignoring event "${existingTitle}" (ID: ${existingEventId}) because its title is in the ignored events list.`);
continue; // Skip this event, don't consider it a conflict
}
}
// --- End event title ignore check ---
// --- Proceed with Conflict Check ---
const existingStatus = existingEvent.getMyStatus();
const existingStart = existingEvent.getStartTime();
const existingEnd = existingEvent.getEndTime();
// Check for valid time data on the existing event
if (!existingStart || !existingEnd) {
Logger.log(`⚠️ Skipping comparison with existing event "${existingTitle}" (ID: ${existingEventId}) due to missing time data.`);
continue; // Skip to next overlapping event
}
// A conflict exists if:
// a) The user hasn't declined the existing event (YES, MAYBE, or OWNER/null status).
// b) The events actually overlap in time (start_existing < end_invite AND end_existing > start_invite).
// c) The duration of the overlap meets the minimum threshold.
if (existingStatus !== CalendarApp.GuestStatus.NO && existingStart < inviteEnd && existingEnd > inviteStart) {
// Calculate overlap duration
const overlapStart = new Date(Math.max(inviteStart.getTime(), existingStart.getTime()));
const overlapEnd = new Date(Math.min(inviteEnd.getTime(), existingEnd.getTime()));
// Ensure overlap is valid (end > start) before calculating duration
if (overlapEnd > overlapStart) {
const overlapMillis = overlapEnd.getTime() - overlapStart.getTime();
// Use Math.floor for a conservative overlap calculation (must fully complete the minute)
const overlapMinutes = Math.floor(overlapMillis / (1000 * 60));
if (overlapMinutes >= config.minOverlapMinutes) {
conflictFound = true;
// Store details concisely for potential use in email notification
conflictingEventDetails = `"${existingTitle}" (${overlapMinutes} min overlap)`; // Removed ID for simplicity in email
Logger.log(`--> Conflict detected with: ${conflictingEventDetails}`);
break; // Found a qualifying conflict, no need to check other overlapping events for this invite.
} else {
// Log ignored overlaps only if they are positive
if (overlapMinutes >= 0) {
Logger.log(`--> Minor overlap (${overlapMinutes} min) with "${existingTitle}" (ID: ${existingEventId}), below threshold (${config.minOverlapMinutes} min). Ignoring.`);
}
}
}
}
} catch (e) {
Logger.log(`⚠️ Error checking existing event "${existingTitle}" (ID: ${existingEventId}) for conflict: ${e.message}. Skipping this comparison.`);
// Continue checking other potentially overlapping events
}
} // End loop through overlapping events
// 6. If a qualifying conflict was found, decline the invite (unless in dryRun mode).
if (conflictFound) {
if (config.dryRun) {
Logger.log(`🧪 [Dry Run] Would decline invite: "${inviteTitle}" (ID: ${inviteId}) due to conflict with ${conflictingEventDetails}.`);
// Simulate incrementing declined count for accurate dry run reporting
declinedCount++;
} else {
Logger.log(`🚫 Declining invite: "${inviteTitle}" (ID: ${inviteId}) due to conflict with ${conflictingEventDetails}.`);
try {
// --- Simplified setMyStatus Call (using Enum as documented) ---
invite.setMyStatus(CalendarApp.GuestStatus.NO);
// --- End Simplified Call ---
declinedCount++;
// Log success immediately after the call
Logger.log(`--> Successfully declined "${inviteTitle}" (ID: ${inviteId}).`);
// Send notification email if enabled
if (config.sendEmailNotifications) {
try {
MailApp.sendEmail(
Session.getActiveUser().getEmail(),
'ℹ️ Calendar Invite Auto-Declined',
`The event "${inviteTitle}" scheduled for ${inviteStart.toLocaleString()} ` +
`was automatically declined by your script due to a conflict with ${conflictingEventDetails}.`
);
} catch (mailErr) {
Logger.log(`🚨 Failed to send decline notification email for event ID ${inviteId}: ${mailErr.message}`);
}
}
// Send notification to the organizer if enabled
if (config.notifyOrganizer) {
try {
// Get the organizer's email
const organizer = invite.getCreators()[0]; // This gets the first creator (usually the organizer)
if (organizer) {
MailApp.sendEmail(
organizer,
'Regarding your calendar invitation: Auto-Declined',
`I wanted to let you know that I won't be able to attend "${inviteTitle}" scheduled for ${inviteStart.toLocaleString()} ` +
`as it conflicts with another meeting in my calendar. Feel free to suggest an alternative time if needed.\n\n` +
`This is an automated message sent by a Google Calendar management script.\n\n` +
`Thank You`
);
Logger.log(`--> Sent notification to organizer: ${organizer}`);
}
} catch (organizerErr) {
Logger.log(`🚨 Failed to send notification to organizer for event ID ${inviteId}: ${organizerErr.message}`);
}
}
} catch (e) {
// Catch error specifically from the setMyStatus call
Logger.log(`🚨 Error attempting to decline event "${inviteTitle}" (ID: ${inviteId}): ${e.message}. Decline failed.`);
// If setMyStatus fails, don't increment declinedCount (or decrement if needed, though less clean)
// For simplicity, we assume if error caught here, decline didn't happen.
}
}
} else {
Logger.log(`👍 No qualifying conflicts found for "${inviteTitle}" (ID: ${inviteId}).`);
}
} catch (e) {
// Catch errors processing a specific invite (e.g., getting ID, title, times), log, and continue with the next invite
Logger.log(`🚨 Error processing invite (ID: ${inviteId}): ${e.message}\n${e.stack}. Continuing...`);
}
}); // End loop through potential invites
} catch (e) {
// Catch errors related to fetching events for the range (e.g., Calendar Service errors)
Logger.log(`🚨 Error processing date range ${startDate.toLocaleString()} - ${endDate.toLocaleString()}: ${e.message}\n${e.stack}`);
// Optionally re-throw or notify if this is critical depending on desired script behavior on failure
// throw e; // Uncomment to potentially stop the script on range processing errors
}
return declinedCount;
}
/**
* Helper function to run the script in TEST (Dry Run) mode.
* Select this function from the dropdown in the Apps Script editor and click "Run".
* It will use the main CONFIG settings but force dryRun = true,
* logging actions without modifying your calendar.
* IMPORTANT: This function temporarily modifies the global CONFIG object for the duration
* of its execution and restores it using a finally block.
*/
function testAutoDeclineWithoutDeclining() {
Logger.log("--- Starting Test Run (Dry Run Mode) ---");
// Backup the original configuration
const originalConfig = JSON.parse(JSON.stringify(CONFIG));
try {
// --- Temporarily override global CONFIG for this test run ---
CONFIG.dryRun = true; // Force dry run globally for this execution
Logger.log(`Forcing dryRun = ${CONFIG.dryRun} for this test execution.`);
// --- Run the main logic with the dryRun setting forced ---
autoDeclineConflictingInvites(); // Call the main function which now respects the overridden CONFIG.dryRun
} catch(e) {
// Log any errors that occur during the test run itself
Logger.log(`🚨 Error during test run execution: ${e.message}\n${e.stack}`);
} finally {
// --- Restore the original global CONFIG ---
// Use 'finally' to ensure the original configuration is restored
// even if the script encounters an error during the test run.
for (const key in originalConfig) {
if (originalConfig.hasOwnProperty(key)) {
CONFIG[key] = originalConfig[key];
}
}
Logger.log(`Original configuration restored. dryRun is now: ${CONFIG.dryRun}`);
Logger.log("--- Test Run Finished ---");
}
}
@spencerthayer
Copy link
Author

Here’s how to install, configure, test and schedule the auto‑decline script in your Google Calendar via Apps Script:

  1. Create a new Apps Script project

    1. Go to https://script.google.com/ and click New project.
    2. In the top‐left, give it a name like “Calendar Auto‑Decline.”
  2. Paste in the script

    1. In the default Code.gs, delete any boilerplate.
    2. Copy your entire script (from /** Google Calendar Auto‑Decline… through to the closing } of testAutoDeclineWithoutDeclining()) and paste it into Code.gs.
    3. Click File → Save.
  3. Adjust your configuration
    At the top of the file you’ll see the CONFIG object. Modify these to suit your preferences:

    const CONFIG = {
      lookAheadDays: 7,            // how far ahead to scan
      minOverlapMinutes: 1,        // any overlap ≥ 1 min triggers decline
      sendEmailNotifications: true, // notify yourself on each auto-decline
      sendErrorNotifications: true, // notify yourself if the script errors
      notifyOrganizer: true,       // email the organizer when declining
      processInBatches: false,     // split into smaller date chunks?
      batchSizeInDays: 3,          // days per batch if above = true
      ignoreEvents: [              // exact titles to skip when checking
        "Home", "AM All Hands", "PM All Hands", 
        "Afternoon Check‑in", "Fireside Chat", "Studio Monthly"
      ],
      dryRun: false                // set true to TEST without actually declining
    };

    Tip: start with dryRun: true so you can inspect what would happen without affecting your calendar.

  4. Authorize & run a dry‑run test

    1. In the Apps Script editor, select the function dropdown (just above the code) and choose testAutoDeclineWithoutDeclining.

    2. Click the ▶️ Run button.

    3. The first time, you’ll be prompted to grant:

      • View and manage your calendars
      • Send email as you
    4. Approve the scopes.

    5. Open View → Logs to see which events would be declined.

  5. Switch off dry‑run & test live

    1. Back in Code.gs, set dryRun: false.
    2. Run autoDeclineConflictingInvites once manually (▶️ Run) to make sure it actually declines any detected conflicts.
    3. Inspect your calendar and email inbox for decline notices.
  6. Set up an automatic trigger

    1. In the left sidebar, click the ⏰ Triggers icon.

    2. Click + Add Trigger (bottom right).

    3. Configure:

      • Choose which function to run: autoDeclineConflictingInvites
      • Deployment: Head
      • Select event source: Time‑driven
      • Type of time based trigger: Minutes timer → every 15 minutes (or 30 mins, whichever you prefer)
    4. Click Save.

  7. Verify

    • Return to Triggers to confirm it’s active.
    • Check your Executions (in the left sidebar) after a few runs to confirm success.
    • Look in your Calendar and Email for the expected decline notices.

🎉 That’s it! Your Calendar will now automatically decline any incoming invites that conflict with your existing events—complete with optional email alerts and organizer notifications.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment