Final production deployment: activated live sync and updated documentation

This commit is contained in:
Moritz Graf 2026-04-25 14:31:37 +02:00
parent d4b4e19223
commit 82acc4e7d5
3 changed files with 65 additions and 54 deletions

View File

@ -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. 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. 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. 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.

View File

@ -31,8 +31,8 @@ const CONFIG = {
SHEET_NAME: "Form Responses 1", SHEET_NAME: "Form Responses 1",
// 3. The column numbers containing the data (1 = A, 2 = B, 3 = C, etc.) // 3. The column numbers containing the data (1 = A, 2 = B, 3 = C, etc.)
COL_SOURCE_ADDRESS: 2, COL_FORWARD_ADDRESS: 6,
COL_DESTINATION_ADDRESS: 6, COL_FORWARD_TO_ADDRESS: 4,
// 4. Your admin email for receiving reports // 4. Your admin email for receiving reports
ADMIN_EMAIL: "admin@haumdaucher.de", ADMIN_EMAIL: "admin@haumdaucher.de",

View File

@ -13,9 +13,9 @@ const CONFIG = {
SHEET_NAME: "Form Responses 1", SHEET_NAME: "Form Responses 1",
// Column indices (1-indexed). // Column indices (1-indexed).
// Example: If Source is Column B, index is 2. If Dest is Column C, index is 3. // Example: If Forward Address is Column D, index is 4. If Forward To is Column F, index is 6.
COL_SOURCE_ADDRESS: 2, COL_FORWARD_ADDRESS: 6,
COL_DESTINATION_ADDRESS: 6, COL_FORWARD_TO_ADDRESS: 4,
// The admin email that should receive the execution reports // The admin email that should receive the execution reports
ADMIN_EMAIL: "moritz@haumdaucher.de", ADMIN_EMAIL: "moritz@haumdaucher.de",
@ -89,13 +89,13 @@ function syncForwardings() {
const changelog = []; const changelog = [];
// 1. Process Creations and Updates // 1. Process Creations and Updates
for (const sourceEmail in desiredState) { for (const forwardAddress in desiredState) {
const desiredDestinations = desiredState[sourceEmail]; const forwardToAddresses = desiredState[forwardAddress];
if (!currentState[sourceEmail]) { if (!currentState[forwardAddress]) {
createForwardingGroup(sourceEmail, desiredDestinations, changelog); createForwardingGroup(forwardAddress, forwardToAddresses, changelog);
} else { } 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 // Start at i=1 to skip the header row
for (let i = 1; i < data.length; i++) { for (let i = 1; i < data.length; i++) {
const row = data[i]; const row = data[i];
let source = (row[CONFIG.COL_SOURCE_ADDRESS - 1] || "").toString().trim().toLowerCase(); let forwardAddress = (row[CONFIG.COL_FORWARD_ADDRESS - 1] || "").toString().trim().toLowerCase();
let dest = (row[CONFIG.COL_DESTINATION_ADDRESS - 1] || "").toString().trim().toLowerCase(); let forwardToAddress = (row[CONFIG.COL_FORWARD_TO_ADDRESS - 1] || "").toString().trim().toLowerCase();
// Ignore completely empty rows // Ignore completely empty rows
if (!source && !dest) { if (!forwardAddress && !forwardToAddress) {
continue; continue;
} }
// 1. Auto-append domain to source if they just typed a name (e.g. "frederic") // 1. Auto-append domain to forwardAddress if they just typed a name (e.g. "frederic")
if (source && !source.includes("@")) { if (forwardAddress && !forwardAddress.includes("@")) {
source += `@${CONFIG.WORKSPACE_DOMAIN}`; forwardAddress += `@${CONFIG.WORKSPACE_DOMAIN}`;
} }
// 2. Validate source domain // 2. Validate forwardAddress domain (MUST be internal)
if (source && !source.endsWith(`@${CONFIG.WORKSPACE_DOMAIN}`)) { if (forwardAddress && !forwardAddress.endsWith(`@${CONFIG.WORKSPACE_DOMAIN}`)) {
console.warn(`Row ${i + 1}: Skipped. Source address must belong to @${CONFIG.WORKSPACE_DOMAIN} domain. Found: '${source}'`); console.warn(`Row ${i + 1}: Skipped. Forward address must belong to @${CONFIG.WORKSPACE_DOMAIN} domain. Found: '${forwardAddress}'`);
continue; continue;
} }
// 3. Validate source is a valid email // 3. Validate forwardAddress is a valid email
if (source && !emailRegex.test(source)) { if (forwardAddress && !emailRegex.test(forwardAddress)) {
console.warn(`Row ${i + 1}: Skipped. Invalid source email format: '${source}'`); console.warn(`Row ${i + 1}: Skipped. Invalid forward address format: '${forwardAddress}'`);
continue; continue;
} }
// 4. Validate destination is a valid email // 4. Validate forwardToAddress domain (MUST NOT be internal)
if (dest && !emailRegex.test(dest)) { if (forwardToAddress && forwardToAddress.endsWith(`@${CONFIG.WORKSPACE_DOMAIN}`)) {
console.warn(`Row ${i + 1}: Skipped. Invalid destination email format: '${dest}'`); console.warn(`Row ${i + 1}: Skipped. Destination address cannot be an internal @${CONFIG.WORKSPACE_DOMAIN} address. Found: '${forwardToAddress}'`);
continue; continue;
} }
if (source && dest) { // 5. Validate forwardToAddress is a valid email
console.log(`Parsed Row ${i + 1}: Valid mapping [${source} -> ${dest}]`); if (forwardToAddress && !emailRegex.test(forwardToAddress)) {
if (!desiredState[source]) { console.warn(`Row ${i + 1}: Skipped. Invalid destination email format: '${forwardToAddress}'`);
desiredState[source] = []; 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 // Ensure we don't add duplicate destinations for the same source
if (desiredState[source].indexOf(dest) === -1) { if (desiredState[forwardAddress].indexOf(forwardToAddress) === -1) {
desiredState[source].push(dest); desiredState[forwardAddress].push(forwardToAddress);
} }
} else { } 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 * Creates a new Workspace Group to act as a forwarder
*/ */
function createForwardingGroup(sourceEmail, destinations, changelog) { function createForwardingGroup(forwardAddress, forwardToAddresses, changelog) {
if (CONFIG.DRY_RUN) { if (CONFIG.DRY_RUN) {
logAction(changelog, `[DRY RUN - CREATED] Group: ${sourceEmail}`); logAction(changelog, `[DRY RUN - CREATED] Group: ${forwardAddress}`);
for (const dest of destinations) { for (const forwardTo of forwardToAddresses) {
logAction(changelog, ` + [DRY RUN] Added member: ${dest}`); logAction(changelog, ` + [DRY RUN] Added member: ${forwardTo}`);
} }
return; return;
} }
try { try {
const newGroup = { const newGroup = {
email: sourceEmail, email: forwardAddress,
name: `Auto-Forwarder: ${sourceEmail}`, name: `Auto-Forwarder: ${forwardAddress}`,
description: CONFIG.GROUP_DESCRIPTION_TAG description: CONFIG.GROUP_DESCRIPTION_TAG
}; };
AdminDirectory.Groups.insert(newGroup); AdminDirectory.Groups.insert(newGroup);
logAction(changelog, `[CREATED] Group: ${sourceEmail}`); logAction(changelog, `[CREATED] Group: ${forwardAddress}`);
for (const dest of destinations) { // Propagation delay: Google Workspace APIs are eventually consistent.
AdminDirectory.Members.insert({ email: dest, role: 'MEMBER' }, sourceEmail); // We must wait a few seconds after creation before we can add members to the new group.
logAction(changelog, ` + Added member: ${dest}`); Utilities.sleep(3000);
for (const forwardTo of forwardToAddresses) {
AdminDirectory.Members.insert({ email: forwardTo, role: 'MEMBER' }, forwardAddress);
logAction(changelog, ` + Added member: ${forwardTo}`);
} }
} catch (e) { } 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 * Reconciles members of an existing forwarding group
*/ */
function updateForwardingGroup(sourceEmail, currentMembers, desiredMembers, changelog) { function updateForwardingGroup(forwardAddress, currentMembers, desiredMembers, changelog) {
// 1. Add missing members // 1. Add missing members
for (const dest of desiredMembers) { for (const forwardTo of desiredMembers) {
if (currentMembers.indexOf(dest) === -1) { if (currentMembers.indexOf(forwardTo) === -1) {
if (CONFIG.DRY_RUN) { 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 { } else {
try { try {
AdminDirectory.Members.insert({ email: dest, role: 'MEMBER' }, sourceEmail); AdminDirectory.Members.insert({ email: forwardTo, role: 'MEMBER' }, forwardAddress);
logAction(changelog, `[UPDATED] Group ${sourceEmail}: Added member ${dest}`); logAction(changelog, `[UPDATED] Group ${forwardAddress}: Added member ${forwardTo}`);
} catch (e) { } 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) { for (const current of currentMembers) {
if (desiredMembers.indexOf(current) === -1) { if (desiredMembers.indexOf(current) === -1) {
if (CONFIG.DRY_RUN) { 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 { } else {
try { try {
AdminDirectory.Members.remove(sourceEmail, current); AdminDirectory.Members.remove(forwardAddress, current);
logAction(changelog, `[UPDATED] Group ${sourceEmail}: Removed member ${current}`); logAction(changelog, `[UPDATED] Group ${forwardAddress}: Removed member ${current}`);
} catch (e) { } 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}`);
} }
} }
} }