From 82acc4e7d526fc04f2ef5979ea19799c96eb4057 Mon Sep 17 00:00:00 2001 From: Moritz Graf Date: Sat, 25 Apr 2026 14:31:37 +0200 Subject: [PATCH] Final production deployment: activated live sync and updated documentation --- mail_forwarding/AGENTS.md | 1 + mail_forwarding/README.md | 4 +- mail_forwarding/src/Code.js | 114 ++++++++++++++++++++---------------- 3 files changed, 65 insertions(+), 54 deletions(-) diff --git a/mail_forwarding/AGENTS.md b/mail_forwarding/AGENTS.md index 29260b3..9c13c97 100644 --- a/mail_forwarding/AGENTS.md +++ b/mail_forwarding/AGENTS.md @@ -20,3 +20,4 @@ It automates the creation of email forwarding in Google Workspace by reading fro The script achieves declarative state management by finding and deleting Workspace Groups that are NOT present in the Google Sheet. To prevent the catastrophic deletion of real, human-managed groups (e.g., `board@haumdaucher.de`), the script relies on the `[Auto-Forwarder] Managed by Google Sheets` string in the group's description. The script must ALWAYS filter for this exact string before issuing any deletion API calls. 2. **Always deploy via `clasp`.** Do not instruct the user to copy-paste code manually if `clasp` is available. 3. **Trigger:** We use a combination of `onFormSubmit` (for Google Forms) and `onChange` (for manual sheet edits) triggers. This ensures reconciliation happens regardless of how the data was updated. +4. **Validation Logic**: `forwardAddress` must always be internal (`@haumdaucher.de`), while `forwardToAddress` must always be external (non-haumdaucher) to prevent routing loops. diff --git a/mail_forwarding/README.md b/mail_forwarding/README.md index 702cd4a..06f2d1c 100644 --- a/mail_forwarding/README.md +++ b/mail_forwarding/README.md @@ -31,8 +31,8 @@ const CONFIG = { SHEET_NAME: "Form Responses 1", // 3. The column numbers containing the data (1 = A, 2 = B, 3 = C, etc.) - COL_SOURCE_ADDRESS: 2, - COL_DESTINATION_ADDRESS: 6, + COL_FORWARD_ADDRESS: 6, + COL_FORWARD_TO_ADDRESS: 4, // 4. Your admin email for receiving reports ADMIN_EMAIL: "admin@haumdaucher.de", diff --git a/mail_forwarding/src/Code.js b/mail_forwarding/src/Code.js index 9d6cdd6..cddd22f 100644 --- a/mail_forwarding/src/Code.js +++ b/mail_forwarding/src/Code.js @@ -13,9 +13,9 @@ const CONFIG = { 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, + // Example: If Forward Address is Column D, index is 4. If Forward To is Column F, index is 6. + COL_FORWARD_ADDRESS: 6, + COL_FORWARD_TO_ADDRESS: 4, // The admin email that should receive the execution reports ADMIN_EMAIL: "moritz@haumdaucher.de", @@ -89,13 +89,13 @@ function syncForwardings() { const changelog = []; // 1. Process Creations and Updates - for (const sourceEmail in desiredState) { - const desiredDestinations = desiredState[sourceEmail]; + for (const forwardAddress in desiredState) { + const forwardToAddresses = desiredState[forwardAddress]; - if (!currentState[sourceEmail]) { - createForwardingGroup(sourceEmail, desiredDestinations, changelog); + if (!currentState[forwardAddress]) { + createForwardingGroup(forwardAddress, forwardToAddresses, changelog); } else { - updateForwardingGroup(sourceEmail, currentState[sourceEmail], desiredDestinations, changelog); + updateForwardingGroup(forwardAddress, currentState[forwardAddress], forwardToAddresses, changelog); } } @@ -144,48 +144,54 @@ function readDesiredStateFromSheet() { // 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(); + let forwardAddress = (row[CONFIG.COL_FORWARD_ADDRESS - 1] || "").toString().trim().toLowerCase(); + let forwardToAddress = (row[CONFIG.COL_FORWARD_TO_ADDRESS - 1] || "").toString().trim().toLowerCase(); // Ignore completely empty rows - if (!source && !dest) { + if (!forwardAddress && !forwardToAddress) { continue; } - // 1. Auto-append domain to source if they just typed a name (e.g. "frederic") - if (source && !source.includes("@")) { - source += `@${CONFIG.WORKSPACE_DOMAIN}`; + // 1. Auto-append domain to forwardAddress if they just typed a name (e.g. "frederic") + if (forwardAddress && !forwardAddress.includes("@")) { + forwardAddress += `@${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}'`); + // 2. Validate forwardAddress domain (MUST be internal) + if (forwardAddress && !forwardAddress.endsWith(`@${CONFIG.WORKSPACE_DOMAIN}`)) { + console.warn(`Row ${i + 1}: Skipped. Forward address must belong to @${CONFIG.WORKSPACE_DOMAIN} domain. Found: '${forwardAddress}'`); continue; } - // 3. Validate source is a valid email - if (source && !emailRegex.test(source)) { - console.warn(`Row ${i + 1}: Skipped. Invalid source email format: '${source}'`); + // 3. Validate forwardAddress is a valid email + if (forwardAddress && !emailRegex.test(forwardAddress)) { + console.warn(`Row ${i + 1}: Skipped. Invalid forward address format: '${forwardAddress}'`); continue; } - // 4. Validate destination is a valid email - if (dest && !emailRegex.test(dest)) { - console.warn(`Row ${i + 1}: Skipped. Invalid destination email format: '${dest}'`); + // 4. Validate forwardToAddress domain (MUST NOT be internal) + if (forwardToAddress && forwardToAddress.endsWith(`@${CONFIG.WORKSPACE_DOMAIN}`)) { + console.warn(`Row ${i + 1}: Skipped. Destination address cannot be an internal @${CONFIG.WORKSPACE_DOMAIN} address. Found: '${forwardToAddress}'`); continue; } - if (source && dest) { - console.log(`Parsed Row ${i + 1}: Valid mapping [${source} -> ${dest}]`); - if (!desiredState[source]) { - desiredState[source] = []; + // 5. Validate forwardToAddress is a valid email + if (forwardToAddress && !emailRegex.test(forwardToAddress)) { + console.warn(`Row ${i + 1}: Skipped. Invalid destination email format: '${forwardToAddress}'`); + continue; + } + + if (forwardAddress && forwardToAddress) { + console.log(`Parsed Row ${i + 1}: Valid mapping [${forwardAddress} -> ${forwardToAddress}]`); + if (!desiredState[forwardAddress]) { + desiredState[forwardAddress] = []; } // Ensure we don't add duplicate destinations for the same source - if (desiredState[source].indexOf(dest) === -1) { - desiredState[source].push(dest); + if (desiredState[forwardAddress].indexOf(forwardToAddress) === -1) { + desiredState[forwardAddress].push(forwardToAddress); } } else { - console.warn(`Row ${i + 1}: Skipped. Missing either source or destination (Source: '${source}', Dest: '${dest}')`); + console.warn(`Row ${i + 1}: Skipped. Missing either forward address or destination (Forward: '${forwardAddress}', To: '${forwardToAddress}')`); } } @@ -254,49 +260,53 @@ function logAction(changelog, message) { /** * Creates a new Workspace Group to act as a forwarder */ -function createForwardingGroup(sourceEmail, destinations, changelog) { +function createForwardingGroup(forwardAddress, forwardToAddresses, changelog) { if (CONFIG.DRY_RUN) { - logAction(changelog, `[DRY RUN - CREATED] Group: ${sourceEmail}`); - for (const dest of destinations) { - logAction(changelog, ` + [DRY RUN] Added member: ${dest}`); + logAction(changelog, `[DRY RUN - CREATED] Group: ${forwardAddress}`); + for (const forwardTo of forwardToAddresses) { + logAction(changelog, ` + [DRY RUN] Added member: ${forwardTo}`); } return; } try { const newGroup = { - email: sourceEmail, - name: `Auto-Forwarder: ${sourceEmail}`, + email: forwardAddress, + name: `Auto-Forwarder: ${forwardAddress}`, description: CONFIG.GROUP_DESCRIPTION_TAG }; AdminDirectory.Groups.insert(newGroup); - logAction(changelog, `[CREATED] Group: ${sourceEmail}`); + logAction(changelog, `[CREATED] Group: ${forwardAddress}`); - for (const dest of destinations) { - AdminDirectory.Members.insert({ email: dest, role: 'MEMBER' }, sourceEmail); - logAction(changelog, ` + Added member: ${dest}`); + // Propagation delay: Google Workspace APIs are eventually consistent. + // We must wait a few seconds after creation before we can add members to the new group. + Utilities.sleep(3000); + + for (const forwardTo of forwardToAddresses) { + AdminDirectory.Members.insert({ email: forwardTo, role: 'MEMBER' }, forwardAddress); + logAction(changelog, ` + Added member: ${forwardTo}`); } } catch (e) { - logAction(changelog, `[ERROR] Failed to create group ${sourceEmail}: ${e.message}`); + logAction(changelog, `[ERROR] Failed to create group ${forwardAddress}: ${e.message}`); } } /** * Reconciles members of an existing forwarding group */ -function updateForwardingGroup(sourceEmail, currentMembers, desiredMembers, changelog) { +function updateForwardingGroup(forwardAddress, currentMembers, desiredMembers, changelog) { // 1. Add missing members - for (const dest of desiredMembers) { - if (currentMembers.indexOf(dest) === -1) { + for (const forwardTo of desiredMembers) { + if (currentMembers.indexOf(forwardTo) === -1) { if (CONFIG.DRY_RUN) { - logAction(changelog, `[DRY RUN - UPDATED] Group ${sourceEmail}: Added member ${dest}`); + logAction(changelog, `[DRY RUN - UPDATED] Group ${forwardAddress}: Added member ${forwardTo}`); } else { try { - AdminDirectory.Members.insert({ email: dest, role: 'MEMBER' }, sourceEmail); - logAction(changelog, `[UPDATED] Group ${sourceEmail}: Added member ${dest}`); + AdminDirectory.Members.insert({ email: forwardTo, role: 'MEMBER' }, forwardAddress); + logAction(changelog, `[UPDATED] Group ${forwardAddress}: Added member ${forwardTo}`); } catch (e) { - logAction(changelog, `[ERROR] Failed to add member ${dest} to ${sourceEmail}: ${e.message}`); + logAction(changelog, `[ERROR] Failed to add member ${forwardTo} to ${forwardAddress}: ${e.message}`); } } } @@ -306,13 +316,13 @@ function updateForwardingGroup(sourceEmail, currentMembers, desiredMembers, chan for (const current of currentMembers) { if (desiredMembers.indexOf(current) === -1) { if (CONFIG.DRY_RUN) { - logAction(changelog, `[DRY RUN - UPDATED] Group ${sourceEmail}: Removed member ${current}`); + logAction(changelog, `[DRY RUN - UPDATED] Group ${forwardAddress}: Removed member ${current}`); } else { try { - AdminDirectory.Members.remove(sourceEmail, current); - logAction(changelog, `[UPDATED] Group ${sourceEmail}: Removed member ${current}`); + AdminDirectory.Members.remove(forwardAddress, current); + logAction(changelog, `[UPDATED] Group ${forwardAddress}: Removed member ${current}`); } catch (e) { - logAction(changelog, `[ERROR] Failed to remove member ${current} from ${sourceEmail}: ${e.message}`); + logAction(changelog, `[ERROR] Failed to remove member ${current} from ${forwardAddress}: ${e.message}`); } } }