Working version of the ewmail forwarding appscript

This commit is contained in:
Moritz Graf
2026-04-25 13:09:03 +02:00
parent 1d85634740
commit 37337ffb13
8 changed files with 4033 additions and 0 deletions
+3
View File
@@ -35,3 +35,6 @@ dist-ssr
# Deprecated (Inlined in Terraform) # Deprecated (Inlined in Terraform)
firestore.rules firestore.rules
# Clasp / Google Apps Script
.clasprc.json
+12
View File
@@ -0,0 +1,12 @@
{
"scriptId": "1pTpLk3kblp9xByrZ0rM-5gBU-5pZkbdKR25bzI89apFdU1RHPzBvzIl5",
"rootDir": "src",
"htmlExtensions": [
".html"
],
"jsonExtensions": [
".json"
],
"filePushOrder": [],
"skipSubdirectories": false
}
+18
View File
@@ -0,0 +1,18 @@
# AGENTS.md
This document provides context for AI agents operating on the `mail_forwarding` infrastructure in this repository.
## Architecture
This directory contains Google Apps Script code configured as Infrastructure as Code (IaC).
It automates the creation of email forwarding in Google Workspace by reading from a Google Sheet and dynamically creating/managing Workspace Groups.
## Tooling
- We use `@google/clasp` to manage the deployment of the `.ts` files to Google Apps Script.
- The entrypoint is `src/Code.ts`.
- The manifest is `src/appsscript.json`.
## Rules & Safeguards (CRITICAL)
1. **Never alter the `GROUP_DESCRIPTION_TAG` logic.**
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 an `onChange` trigger instead of `onEdit` because the source sheet is populated automatically via Google Forms. `onEdit` does not fire on Form submissions.
+78
View File
@@ -0,0 +1,78 @@
# Mail Forwarding Automation
This directory contains an Infrastructure-as-Code (IaC) deployment for Google Apps Script.
It automates the creation and synchronization of Google Workspace mail forwarding by reading from a Google Sheet (typically populated by Google Forms) and managing Workspace Groups.
## Prerequisites
To deploy this code to your Google Workspace, you need the following installed on your machine:
- [Node.js & npm](https://nodejs.org/)
- Google Clasp CLI: Run `npm install -g @google/clasp`
## Step 1: Authentication & Setup
Before you can deploy, you must authenticate your local machine with your Google Workspace Admin account and enable the Apps Script API.
1. **Enable the API:** Go to [https://script.google.com/home/usersettings](https://script.google.com/home/usersettings) and turn **ON** the "Google Apps Script API".
2. **Login:** In your terminal, run:
```bash
clasp login
```
This will open a browser window. Sign in with your Workspace Admin account (`@haumdaucher.de`) and grant the necessary permissions.
## Step 2: Configuration
You must configure the script to point to your specific Google Sheet.
Open `src/Code.ts` and modify the `CONFIG` block at the top of the file:
```typescript
const CONFIG = {
// 1. The ID of the Google Sheet (found in the URL: https://docs.google.com/spreadsheets/d/<THIS_ID>/edit)
SPREADSHEET_ID: "YOUR_SHEET_ID_HERE",
// 2. The name of the tab at the bottom of the screen
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: 3,
// 4. Your admin email for receiving reports
ADMIN_EMAIL: "admin@haumdaucher.de",
// 5. Dry run mode. If true, script logs intended changes without modifying Workspace.
DRY_RUN: true,
// ... leave the GROUP_DESCRIPTION_TAG untouched!
};
```
## Step 3: Deployment
Once configured, you need to create an Apps Script project in your Google Account and push this code to it.
1. Navigate to this `mail_forwarding` directory in your terminal.
2. Initialize the project as a standalone script:
```bash
clasp create --type standalone --title "Haumdaucher Mail Forwarding" --rootDir ./src
```
*(This creates a hidden `.clasp.json` file linking this directory to the cloud project).*
3. Push the code:
```bash
clasp push
```
## Step 4: Initialization
The code is now in the cloud, but the background triggers need to be activated and the Admin SDK authorized.
1. Open the project in your browser:
```bash
clasp open-script
```
2. **Ignore the large blue "Deploy" button.** You do *not* need to create a deployment. This script runs via background triggers, not as a web app.
3. In the toolbar directly above the code editor, look for a dropdown menu showing function names (it might currently say `syncForwardings`). Click the dropdown and select `setup`.
4. Click the **Run** button (the play icon) right next to the dropdown.
5. **Authorization Required:** Google will prompt you to review permissions. *(Note: This interactive browser consent screen is a strict Google Workspace security requirement for scripts accessing the Admin API, which is why this specific step cannot be automated via CLI).*
- Click "Review Permissions"
- Choose your Admin account.
- Click "Advanced" -> "Go to Haumdaucher Mail Forwarding (unsafe)"
- Click "Allow" to grant access to the Admin Directory API, Gmail API, and Google Sheets.
5. Once authorized, the `setup` function will finish executing. It installs the background `onChange` trigger.
**You are done!** Whenever a new response is submitted to the configured Google Sheet via Forms, the script will automatically run in the background, reconcile the forwarding groups, and send you an email report.
+3525
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
{
"name": "haumdaucher-mail-forwarding",
"version": "1.0.0",
"description": "Google Apps Script for automating mail forwarding via Workspace Groups",
"scripts": {
"login": "clasp login",
"push": "clasp push"
},
"devDependencies": {
"@google/clasp": "^2.4.2",
"@types/google-apps-script": "^1.0.83"
},
"dependencies": {
"typescript": "^6.0.3"
}
}
+361
View File
@@ -0,0 +1,361 @@
/**
* 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",
// 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 += "@haumdaucher.de";
}
// 2. Validate source domain
if (source && !source.endsWith("@haumdaucher.de")) {
console.warn(`Row ${i + 1}: Skipped. Source address must belong to @haumdaucher.de 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
});
}
+20
View File
@@ -0,0 +1,20 @@
{
"timeZone": "Europe/Berlin",
"dependencies": {
"enabledAdvancedServices": [
{
"userSymbol": "AdminDirectory",
"serviceId": "admin",
"version": "directory_v1"
}
]
},
"exceptionLogging": "STACKDRIVER",
"oauthScopes": [
"https://www.googleapis.com/auth/admin.directory.group",
"https://www.googleapis.com/auth/script.send_mail",
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/script.scriptapp"
],
"runtimeVersion": "V8"
}