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

View File

@ -0,0 +1,12 @@
{
"scriptId": "1pTpLk3kblp9xByrZ0rM-5gBU-5pZkbdKR25bzI89apFdU1RHPzBvzIl5",
"rootDir": "src",
"htmlExtensions": [
".html"
],
"jsonExtensions": [
".json"
],
"filePushOrder": [],
"skipSubdirectories": false
}

18
mail_forwarding/AGENTS.md Normal file
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
mail_forwarding/README.md Normal file
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
mail_forwarding/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

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
mail_forwarding/src/Code.js Normal file
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
});
}

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"
}