Final production deployment: activated live sync and updated documentation
This commit is contained in:
parent
d4b4e19223
commit
82acc4e7d5
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue