/** * CONFIGURATION BLOCK * ------------------- * Update these values before running `npm run push` (clasp push) to deploy * the script to your Google Workspace environment. */ const CONFIG = { // If the script is bound to a sheet (using `clasp clone `), you can leave this empty. // If deployed as a standalone script, YOU MUST provide the exact Spreadsheet ID. SPREADSHEET_ID: "1q4r08nBA_ClWv3ypPCQ6GVCfMVkQwSKzDSRiokkQQ8Q", // The exact name of the tab containing the data (e.g., from Google Forms) SHEET_NAME: "Form Responses 1", // Column indices (1-indexed). // Example: If Source is Column B, index is 2. If Dest is Column C, index is 3. COL_SOURCE_ADDRESS: 2, COL_DESTINATION_ADDRESS: 6, // The admin email that should receive the execution reports ADMIN_EMAIL: "moritz@haumdaucher.de", // The primary Workspace domain for validation and auto-appending WORKSPACE_DOMAIN: "haumdaucher.de", // Dry run mode. If true, the script will only log what it would do and send the email, // but will NOT actually create, update, or delete any groups in Workspace. DRY_RUN: true, // DO NOT CHANGE THIS VALUE. // This tag is added to the description of groups created by this script. // It acts as a safety guard to ensure the script NEVER deletes manually created groups. GROUP_DESCRIPTION_TAG: "[Auto-Forwarder] Managed by Google Sheets" }; /** * INSTALLATION: * Run this function ONCE manually from the Apps Script IDE after pushing. * It authorizes the script and installs the background trigger. */ function setup() { const ss = CONFIG.SPREADSHEET_ID ? SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID) : SpreadsheetApp.getActiveSpreadsheet(); if (!ss) { throw new Error("Could not find spreadsheet. Ensure you are bound to a sheet or have set CONFIG.SPREADSHEET_ID."); } // Clean up any existing triggers to prevent duplicates const triggers = ScriptApp.getProjectTriggers(); for (let i = 0; i < triggers.length; i++) { if (triggers[i].getHandlerFunction() === 'syncForwardings') { ScriptApp.deleteTrigger(triggers[i]); } } // Install the onChange trigger (required for Google Forms integrations) ScriptApp.newTrigger('syncForwardings') .forSpreadsheet(ss) .onChange() .create(); console.log("Setup complete. The 'syncForwardings' trigger has been installed."); } /** * MAIN ENTRY POINT * This is triggered automatically whenever the spreadsheet changes (e.g., new form submission). */ function syncForwardings() { try { console.log("Starting syncForwardings..."); if (CONFIG.DRY_RUN) { console.log("dry_run enabled, no changes will be applied."); } else { console.log("dry_run is false, actively performing changes."); } const desiredState = readDesiredStateFromSheet(); const currentState = readCurrentStateFromWorkspace(); const changelog = []; // 1. Process Creations and Updates for (const sourceEmail in desiredState) { const desiredDestinations = desiredState[sourceEmail]; if (!currentState[sourceEmail]) { createForwardingGroup(sourceEmail, desiredDestinations, changelog); } else { updateForwardingGroup(sourceEmail, currentState[sourceEmail], desiredDestinations, changelog); } } // 2. Process Deletions for (const groupEmail in currentState) { if (!desiredState[groupEmail]) { deleteForwardingGroup(groupEmail, changelog); } } // 3. Send Admin Report sendReport(desiredState, changelog); console.log("Sync complete."); } catch (err) { console.error("Fatal error during syncForwardings:", err); MailApp.sendEmail({ to: CONFIG.ADMIN_EMAIL, subject: "[ERROR] Mail Forwarding Sync Failed", body: "The mail forwarding automation encountered a fatal error:\n\n" + err.stack + "\n\nMessage: " + err.message }); // Re-throw the error so Apps Script dashboard shows "Failed" throw err; } } /** * Reads the Google Sheet and builds a map of Source -> [Destinations] */ function readDesiredStateFromSheet() { const ss = CONFIG.SPREADSHEET_ID ? SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID) : SpreadsheetApp.getActiveSpreadsheet(); const sheet = ss.getSheetByName(CONFIG.SHEET_NAME); if (!sheet) { throw new Error(`Sheet '${CONFIG.SHEET_NAME}' not found.`); } const data = sheet.getDataRange().getValues(); const desiredState = {}; // Regex for basic email validation const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; // Start at i=1 to skip the header row for (let i = 1; i < data.length; i++) { const row = data[i]; let source = (row[CONFIG.COL_SOURCE_ADDRESS - 1] || "").toString().trim().toLowerCase(); let dest = (row[CONFIG.COL_DESTINATION_ADDRESS - 1] || "").toString().trim().toLowerCase(); // Ignore completely empty rows if (!source && !dest) { continue; } // 1. Auto-append domain to source if they just typed a name (e.g. "frederic") if (source && !source.includes("@")) { source += `@${CONFIG.WORKSPACE_DOMAIN}`; } // 2. Validate source domain if (source && !source.endsWith(`@${CONFIG.WORKSPACE_DOMAIN}`)) { console.warn(`Row ${i + 1}: Skipped. Source address must belong to @${CONFIG.WORKSPACE_DOMAIN} domain. Found: '${source}'`); continue; } // 3. Validate source is a valid email if (source && !emailRegex.test(source)) { console.warn(`Row ${i + 1}: Skipped. Invalid source email format: '${source}'`); continue; } // 4. Validate destination is a valid email if (dest && !emailRegex.test(dest)) { console.warn(`Row ${i + 1}: Skipped. Invalid destination email format: '${dest}'`); continue; } if (source && dest) { console.log(`Parsed Row ${i + 1}: Valid mapping [${source} -> ${dest}]`); if (!desiredState[source]) { desiredState[source] = []; } // Ensure we don't add duplicate destinations for the same source if (desiredState[source].indexOf(dest) === -1) { desiredState[source].push(dest); } } else { console.warn(`Row ${i + 1}: Skipped. Missing either source or destination (Source: '${source}', Dest: '${dest}')`); } } return desiredState; } /** * Queries the Admin Directory API for existing Groups managed by this script */ function readCurrentStateFromWorkspace() { const currentState = {}; // customer: 'my_customer' is a special alias for the primary domain let pageToken; do { const response = AdminDirectory.Groups.list({ customer: 'my_customer', pageToken: pageToken }); const groups = response.groups || []; for (const group of groups) { // ONLY process groups that have our specific safety tag if (group.description && group.description.indexOf(CONFIG.GROUP_DESCRIPTION_TAG) !== -1) { const email = group.email.toLowerCase(); currentState[email] = getGroupMembers(email); } } pageToken = response.nextPageToken; } while (pageToken); return currentState; } /** * Fetches all members for a specific group */ function getGroupMembers(groupEmail) { const members = []; let pageToken; do { const response = AdminDirectory.Members.list(groupEmail, { pageToken: pageToken }); const memberList = response.members || []; for (const member of memberList) { if (member.email) { members.push(member.email.toLowerCase()); } } pageToken = response.nextPageToken; } while (pageToken); return members; } function logAction(changelog, message) { console.log(message); changelog.push(message); } /** * Creates a new Workspace Group to act as a forwarder */ function createForwardingGroup(sourceEmail, destinations, changelog) { if (CONFIG.DRY_RUN) { logAction(changelog, `[DRY RUN - CREATED] Group: ${sourceEmail}`); for (const dest of destinations) { logAction(changelog, ` + [DRY RUN] Added member: ${dest}`); } return; } try { const newGroup = { email: sourceEmail, name: `Auto-Forwarder: ${sourceEmail}`, description: CONFIG.GROUP_DESCRIPTION_TAG }; AdminDirectory.Groups.insert(newGroup); logAction(changelog, `[CREATED] Group: ${sourceEmail}`); for (const dest of destinations) { AdminDirectory.Members.insert({ email: dest, role: 'MEMBER' }, sourceEmail); logAction(changelog, ` + Added member: ${dest}`); } } catch (e) { logAction(changelog, `[ERROR] Failed to create group ${sourceEmail}: ${e.message}`); } } /** * Reconciles members of an existing forwarding group */ function updateForwardingGroup(sourceEmail, currentMembers, desiredMembers, changelog) { // 1. Add missing members for (const dest of desiredMembers) { if (currentMembers.indexOf(dest) === -1) { if (CONFIG.DRY_RUN) { logAction(changelog, `[DRY RUN - UPDATED] Group ${sourceEmail}: Added member ${dest}`); } else { try { AdminDirectory.Members.insert({ email: dest, role: 'MEMBER' }, sourceEmail); logAction(changelog, `[UPDATED] Group ${sourceEmail}: Added member ${dest}`); } catch (e) { logAction(changelog, `[ERROR] Failed to add member ${dest} to ${sourceEmail}: ${e.message}`); } } } } // 2. Remove obsolete members for (const current of currentMembers) { if (desiredMembers.indexOf(current) === -1) { if (CONFIG.DRY_RUN) { logAction(changelog, `[DRY RUN - UPDATED] Group ${sourceEmail}: Removed member ${current}`); } else { try { AdminDirectory.Members.remove(sourceEmail, current); logAction(changelog, `[UPDATED] Group ${sourceEmail}: Removed member ${current}`); } catch (e) { logAction(changelog, `[ERROR] Failed to remove member ${current} from ${sourceEmail}: ${e.message}`); } } } } } /** * Deletes a forwarding group (Requires the safety tag check performed during read) */ function deleteForwardingGroup(groupEmail, changelog) { if (CONFIG.DRY_RUN) { logAction(changelog, `[DRY RUN - DELETED] Group: ${groupEmail}`); return; } try { AdminDirectory.Groups.remove(groupEmail); logAction(changelog, `[DELETED] Group: ${groupEmail}`); } catch (e) { logAction(changelog, `[ERROR] Failed to delete group ${groupEmail}: ${e.message}`); } } /** * Sends a summary report to the configured Admin Email */ function sendReport(desiredState, changelog) { if (changelog.length === 0) { console.log("No changes detected. Skipping email report."); return; } const subject = "[Haumdaucher] Mail Forwarding Sync Report"; let body = "The mail forwarding automation has executed successfully.\n\n"; body += "=== CHANGELOG ===\n"; body += changelog.join("\n") + "\n\n"; body += "=== ACTIVE FORWARDINGS ===\n"; const sources = Object.keys(desiredState).sort(); if (sources.length === 0) { body += "No active forwardings configured in the sheet.\n"; } else { for (const source of sources) { body += `${source} -> ${desiredState[source].join(", ")}\n`; } } MailApp.sendEmail({ to: CONFIG.ADMIN_EMAIL, subject: subject, body: body }); }