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

View File

@ -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",

View File

@ -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}`);
}
}
}