365 lines
11 KiB
JavaScript
365 lines
11 KiB
JavaScript
/**
|
|
* 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 <id>`), 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
|
|
});
|
|
}
|