Skip to content

Instantly share code, notes, and snippets.

@wodin
Last active January 28, 2026 08:27
Show Gist options
  • Select an option

  • Save wodin/f360f89fc87f3d1a848f9132cef91850 to your computer and use it in GitHub Desktop.

Select an option

Save wodin/f360f89fc87f3d1a848f9132cef91850 to your computer and use it in GitHub Desktop.
Google Workspace Inbound Gateway Setup (keep your own MX)

Gmail Message-ID Deduplication Problem

The Problem

Gmail deduplicates messages by Message-ID within a single mailbox. When forwarding mail from multiple recipients through a single Google Workspace account, CC'd messages only reach one person.

Scenario

  1. External sender sends email to [email protected] and [email protected] (CC'd)
  2. Both copies have identical Message-ID: <[email protected]>
  3. Postfix forwards both to [email protected] (Workspace account with filters)
  4. Gmail sees same Message-ID twice → keeps only ONE copy
  5. Only one Delivered-To header survives
  6. Workspace filters forward to personal Gmail based on Delivered-To
  7. Result: only Alice OR Bob gets the email, not both

Why This Happens

Gmail's dedup is per-mailbox. When using a single Workspace account as a forwarding hub, all incoming mail hits the same mailbox before filters run. By then, the duplicate is already discarded.

The Solution: gws-forward

Distribute mail across multiple Workspace accounts (bbfilter, bbfilter2, bbfilter3), each with identical filters. A custom Postfix pipe transport tracks which Message-IDs have gone to which accounts.

How It Works

                                    ┌─► [email protected]  ─► filters ─► [email protected]
                                    │                                    ─► [email protected]
sender ─► camissa MX ─► gws-forward ┼─► [email protected] ─► filters ─► [email protected]
                           binary   │                                    ─► [email protected]
                                    └─► [email protected] ─► filters ─► [email protected]
                                                                         ─► [email protected]
  1. Postfix invokes gws-forward as a pipe transport for each recipient
  2. gws-forward reads the message, extracts Message-ID
  3. SQLite tracks which accounts have seen this Message-ID
  4. First recipient → preferred account (hash-based)
  5. Second recipient (same Message-ID) → different account
  6. Third recipient → third account
  7. Each Workspace account has identical filters that forward based on Delivered-To

Concurrency Handling

Postfix may deliver the same message to multiple recipients simultaneously. The binary uses SQLite's BEGIN IMMEDIATE transaction to atomically select and record account assignments, preventing two processes from picking the same account.

Overflow Handling (4+ recipients)

With only 3 accounts, the 4th+ recipient would cause Gmail dedup again. Solution:

  1. Modify the Message-ID: <[email protected]><[email protected]>
  2. Send warning email to affected recipients
  3. Risk: breaks DKIM if Message-ID is signed (~8% of messages)

Cost

3 × ~R120/month = ~R360/month (vs R2565/month for 11 individual Workspace accounts)

Implementation

Go binary: gws-forward (if published) or local at ~/work/gws-forward/

Postfix master.cf

gwsforward unix - n n - - pipe
  flags=DRhu user=virtual:virtual
  argv=/usr/local/bin/gws-forward ${recipient} ${sender} ${queue_id}

Key Files

File Purpose
/usr/local/bin/gws-forward Go binary
/etc/gws-forward.conf Config (accounts, DB path, logging)
/var/lib/gws-forward/messages.db SQLite tracking DB
/var/log/gws-forward/gws-forward.log Logs with Postfix queue ID

Cleanup

# /etc/cron.d/gws-forward-cleanup
0 3 * * * root /usr/local/bin/gws-forward --cleanup

Uses retention_hours from config (default 48h). Message-IDs don't repeat after that window.

Manual Testing

Test gws-forward before going live using --dry-run-full (full routing with DB but skip sendmail):

# Create test config
cat > /tmp/test-gws.conf << 'CONF'
[database]
path = /tmp/test-gws.db
retention_hours = 48

[workspace]
accounts = [email protected],[email protected],[email protected]

[warning]
enabled = false

[logging]
level = debug
file = /tmp/test-gws.log
CONF

# Clean slate
rm -f /tmp/test-gws.db /tmp/test-gws.log

# Test first recipient
echo -e "Message-ID: <[email protected]>\nFrom: [email protected]\nSubject: Test\n\nBody" | \
  ./gws-forward --config /tmp/test-gws.conf --dry-run-full [email protected] [email protected] Q001

# Test second recipient (same Message-ID) - should get different account
echo -e "Message-ID: <[email protected]>\nFrom: [email protected]\nSubject: Test\n\nBody" | \
  ./gws-forward --config /tmp/test-gws.conf --dry-run-full [email protected] [email protected] Q002

# Inspect DB
sqlite3 /tmp/test-gws.db "SELECT * FROM message_routing"

# Check logs
cat /tmp/test-gws.log

Testing overflow (4+ recipients):

rm -f /tmp/test-gws.db

# Fill all 3 accounts
for r in alice bob charlie; do
  echo -e "Message-ID: <[email protected]>\nFrom: [email protected]\nSubject: Test\n\nBody" | \
    ./gws-forward --config /tmp/test-gws.conf --dry-run-full ${r}@bluebird.co.za [email protected] Q$r
done

# 4th recipient triggers overflow
echo -e "Message-ID: <[email protected]>\nFrom: [email protected]\nSubject: Test\n\nBody" | \
  ./gws-forward --config /tmp/test-gws.conf --dry-run-full [email protected] [email protected] Qdave

# Check logs for overflow warning
grep -i overflow /tmp/test-gws.log

Modes:

Flag DB Access Sends Mail Use Case
--dry-run No No Quick header parsing check
--dry-run-full Yes No Full routing test, records to DB
(none) Yes Yes Production

Why "Hare-Brained"

  • Relies on Gmail's undocumented dedup behavior
  • Overflow breaks DKIM for some senders
  • Adds complexity and a new failure mode to mail delivery

But it works, and it's cheaper than 11 Workspace accounts.

Google Workspace Account Conversion Warning

The Problem

When setting up Google Workspace, if the admin is signed into a personal @gmail.com account, Google may convert that personal account into the Workspace account rather than creating a fresh mailbox.

Symptoms

  • New Workspace user (e.g., [email protected]) shows years of old mail
  • Storage is already full (inherited from personal account)
  • "Contact information" during setup showed the personal Gmail address

What Happened

Google "upgraded" the personal account to become a managed Workspace account:

  • Personal account was renamed to the Workspace address
  • All mail, contacts, Drive files came with it
  • The original @gmail.com address may no longer exist

How to Verify

Check 1: Try Signing Into the Original Gmail

  1. Open incognito window
  2. Go to gmail.com
  3. Try signing in with the original @gmail.com address
  4. If it fails or redirects → conversion happened

Check 2: Admin Console User Details

  1. Go to admin.google.com
  2. Directory → Users → Click the user
  3. Look at "Account created" date
  4. If it's years old (not recent) → conversion happened

DO NOT Delete the User

If conversion happened, deleting the user in Admin console will delete years of personal email. Do not do this.

Safe Path Forward

  1. Leave the converted account alone for now
  2. Create a fresh test user (e.g., [email protected]) for mail forwarding experiments
  3. Research before acting on the converted account

Options for the Converted Account

Option A: Create Second User, Ignore the Problem

  • Use a fresh user for your actual needs
  • Converted account sits there, maybe used for admin only
  • Low risk, easy

Option B: Unmanage the Account

  • Google may allow "releasing" a converted account back to personal
  • Check Admin console for this option
  • Research current Google documentation first

Option C: Cancel Workspace Trial

  • RISKY - behavior unclear
  • Might convert account back to personal
  • Might delete everything
  • Contact Google support before trying this

Option D: Contact Google Support

  • Workspace trials include support
  • They can clarify exactly what will happen
  • Recommended before any destructive action

Lessons Learned

When setting up Google Workspace:

  1. Use an incognito window or sign out of all Google accounts first
  2. Don't use a personal Gmail as the initial admin if you want to keep it separate
  3. Create the admin account fresh with the new domain address

References

Google Workspace Day 1 Warning: What We Learned the Hard Way

Date: January 2026

TL;DR

Setting up a Google Workspace trial nearly destroyed a personal Gmail account with 211,736 emails. Everything was recoverable, but the experience was terrifying and poorly documented.

What Went Wrong

The Account Conversion Trap

When setting up Google Workspace while signed into a personal Gmail account, Google may silently convert that personal account into a Workspace-managed account. This happens without clear warning.

Symptoms:

  • Personal Gmail suddenly shows "Managed by yourdomain.com"
  • Years of personal email appears under a new Workspace address
  • Storage shows as full (inherited from personal account)
  • "Important changes to your account" message on login

The Documentation Problem

Google's docs say things like:

"After you cancel your Google Workspace subscription, your users' Google Workspace data will be deleted and can't be restored."

This is terrifying and wrong for converted personal accounts. The actual behavior:

  • Personal account data is preserved
  • Account converts back to personal/consumer status
  • Everything works fine

But you won't know that from reading the docs.

The Support Experience

  • Chatbot: Gave contradictory answers, didn't understand the scenario
  • First support agent: Suggested deleting the user (would have deleted all data!)
  • Specialist team: Finally gave correct instructions
  • Ticket number: Essential for documentation - get one

How to Avoid This

Before Starting a Workspace Trial

  1. Use an incognito/private window
  2. Sign out of ALL Google accounts first
  3. Never set up Workspace while signed into a personal Gmail
  4. Create only fresh users - never convert existing accounts

If You Already Converted an Account

See: google-workspace-unmerge-confirmed-process.md in this gist

Short version:

  1. Do a Google Takeout backup first
  2. Create a new Super Admin user
  3. Remove the converted account from Workspace (choose "Do not Transfer Data")
  4. Account reverts to personal with all data intact

The "Activate Gmail" Mystery

After recovery, Gmail may show an "Activate Gmail" or "Set up Gmail to receive emails" banner.

What it does: Nothing useful. Clicking it tries to open the Workspace Admin console (which doesn't apply to a personal account).

What to do: Click it to make the banner go away, then close any admin.google.com tabs it opens. Or just click Dismiss.

Email already works - the banner is just stale UI from the conversion.

Timeline of Our Experience

Time Event
0:00 Started Workspace trial
0:05 Noticed personal Gmail was "converted"
0:10 Panic - 211,736 emails at risk
0:30 Chatbot giving mixed messages
1:00 Started Google Takeout (60GB backup)
1:30 Contacted human support
1:45 Escalated to specialist team
2:00 Got confirmed process from specialist
2:15 Successfully removed account from Workspace
2:20 All data preserved, email working
2:30 Dismissed mysterious "Activate Gmail" banner

Key Lessons

  1. Google's Workspace onboarding can silently convert personal accounts - This is a known issue ("I can't count how many customers contacting us with this issue" - Google Support)

  2. The documentation is wrong/misleading - Don't trust warnings about "permanent deletion" for converted accounts

  3. Always do Takeout backup first - Even when support says you don't need it

  4. Get a human specialist - The chatbot and first-tier support may not understand converted accounts

  5. Get a ticket number - Document everything in case you need to escalate

  6. Day 1 of Workspace trial may be spent recovering from Workspace - Budget your time accordingly

References

Google Workspace Inbound Gateway Setup

Goal

Set up Google Workspace to receive forwarded mail from our mail server WITHOUT changing MX records.

Prerequisites

  • Google Workspace account created for intelms.com
  • Domain verified in Google Workspace
  • Test user created (e.g., [email protected])

Steps in Google Admin Console

1. Access Gmail Settings

admin.google.com → Apps → Google Workspace → Gmail

2. Configure Inbound Gateway

Gmail → Spam, Phishing and Malware → Inbound gateway

Click "Configure" or "Add another" and set:

  • Gateway IPs: Add our mail server's public IP address
  • Automatically detect external IP: Enable this (recommended)
  • Reject all mail not from gateway IPs: Leave DISABLED for testing
  • Require TLS for connections from the email gateways: Enable if our server supports it
  • Message is spam if the following header regexp matches: Leave empty

3. Save and Wait

Changes can take up to 24 hours to propagate, but usually faster.

What This Does

  • Tells Gmail our server is a trusted forwarder
  • Gmail checks SPF/DKIM against the ORIGINAL sender, not our server
  • User filters trigger normally (because it's real SMTP delivery)
  • Spam filtering works against original sender

Testing

  1. Send test email from external address to [email protected]
  2. Check Gmail inbox (not spam folder)
  3. Verify headers show correct SPF/DKIM results

Troubleshooting

  • If mail goes to spam: Check inbound gateway IP is correct
  • If mail rejected: Ensure Google Workspace accepts mail for the domain
  • Check headers for X-Gm-Spam and X-Gm-Phishy values

References

Google Workspace Safe Cancellation Process

For Domain-Verified Subscriptions (with converted personal accounts)

If you set up Google Workspace and a personal Google account got converted to a managed account, follow this two-step process to safely revert it.

How to Know If This Applies

  • You verified domain ownership via DNS TXT record
  • A personal account now shows as "Managed by yourdomain.com"
  • You want to restore the account to a personal, unmanaged state

The Two-Step Process

Step 1: Cancel the Subscription

  1. Sign in to Admin console: admin.google.com
  2. Go to Menu → Billing → Subscriptions
  3. Click your subscription → More → Cancel Subscription
  4. Follow prompts

After this step: User accounts still exist, paid services are cancelled.

Step 2: Delete the Organization's Google Account

This is the critical step that converts managed accounts back to personal.

  1. Go to Menu → Account → Account settings → Account management
  2. Click Delete Account
  3. Follow prompts to confirm

After this step: User account is converted back to a standard, unmanaged Google Account with all data retained.

Important Notes

  • The "data will be deleted" warning in Step 1 is about Workspace-specific data
  • The organization deletion in Step 2 is what actually "releases" accounts
  • Personal account data is preserved through this process

Official Documentation

Recommendation

Test this process with a throwaway account first before doing it with important data.

Confirmed Process: Unmerge Personal Gmail from Google Workspace

Source: Google Workspace Support (Ticket #67121438, January 2026)

Status: CONFIRMED WORKING

The Problem

When setting up Google Workspace, a personal Gmail account can get "converted" to a managed Workspace account. This document describes the confirmed process to restore it to a personal account.

Symptoms of a Converted Account

  • Personal Gmail shows as "Managed by yourdomain.com"
  • Old personal emails appear in the Workspace account
  • Profile shows new Workspace address (e.g., [email protected]) but contains years of old mail
  • "Important changes to your account" message appeared during login

Confirmed Solution (from Google Support)

Prerequisites

  1. Complete a Google Takeout backup first - Recommended even though support says it's not needed
  2. Note your ticket number if you contact support (for documentation if something goes wrong)

Step 1: Create a New Super Admin

  1. In Admin console (admin.google.com), create a new user
  2. Grant Super Admin privileges to this new user
  3. Sign out and sign back in as the new Super Admin

Step 2: Remove Admin Role from Converted Account

  1. Go to Directory → Users
  2. Find the converted account (e.g., [email protected])
  3. Remove the Super Admin role from this account

Step 3: Remove the User from Workspace

  1. Click on the converted user account
  2. Click REMOVE USER
  3. Choose "Do not Transfer Data"

Important: Support confirmed: "All data from personal emails are safe and secured."

Step 4: Complete the Conversion

After removal, when logging into the original Gmail address, you'll see:

"Your account has been removed from an organisation" "Your admin has converted your managed Google Account to a consumer account."

If you used Google Workspace with Gmail: You may see a prompt to "add Gmail" to your account within 30 days. Click "Next", agree to terms and conditions.

Step 5: Verify

  1. Sign into the original personal Gmail address (e.g., [email protected])
  2. Follow any prompts to re-enable Gmail / agree to terms
  3. Verify all emails and data are present
  4. Test sending an email TO the account to confirm receiving works

Confirmed Outcome

Result: 211,736 emails preserved. Sending and receiving works immediately.

You may still see an "Activate Gmail" / "Set up Gmail to receive emails" banner. This can be ignored or dismissed - email works without clicking it.

Safety Net: 20-Day Restore Window

Support confirmed: "Once you deleted the user, you have 20 days to restore the user and its data."

If something goes wrong, you can restore within this window from Admin console: Directory → Users → "Add a filter" → Show deleted users

What Support Said

"I can't count how many customers contacting us with this issue."

The documentation is misleading. The warnings about "permanent deletion" refer to Workspace-specific data, not the original personal account data.

Warnings

  • Always do a Takeout backup first - Even with support confirmation, protect yourself
  • The docs are scary and wrong - They warn about permanent deletion but that's not what happens for converted personal accounts
  • Get a ticket number - If you contact support, document the conversation
  • Ignore "Activate Gmail" banner - Email works without it

Summary

  1. Personal Gmail got converted to Workspace-managed account during trial setup
  2. Created new Super Admin, removed converted account from Workspace with "Do not Transfer Data"
  3. Account converted back to personal consumer account
  4. All data preserved, email working immediately

The support agent was right. The documentation was terrifying and wrong.

References

Postfix Configuration for Google Workspace Forwarding

Forward specific users' mail to Google Workspace while keeping your server as the MX.

Scenario

  • Your server is the primary MX for the domain
  • Some addresses are local mailboxes (LDAP-based virtual mailboxes)
  • Some addresses are aliases to local mailboxes (e.g., [email protected][email protected])
  • Some addresses should forward to Google Workspace

Key Insight: Aliases Flow Through

If you have:

Then mail to [email protected] will:

  1. Be rewritten to [email protected] (alias)
  2. Hit the transport map
  3. Go to Google Workspace

You only need to add bluebird.co.za addresses to the transport map — aliases automatically follow.

Configuration

1. For domains with ONLY aliases (e.g., intelms.com)

If you have addresses that exist ONLY in Google Workspace (no local alias), move the domain to relay_domains.

In /etc/postfix/main.cf:

# Remove intelms.com from virtual_mailbox_domains
virtual_mailbox_domains = bluebird.co.za radiology.co.za intelms.com.au intelms.ca

# Add it to relay_domains instead
relay_domains = intelms.com

Configure recipient validation to prevent accepting garbage:

relay_recipient_maps = hash:/etc/postfix/intelms.com hash:/etc/postfix/relay_recipients
  • First file: existing aliases (validates aliased addresses)
  • Second file: Workspace-only addresses with no local alias

Create /etc/postfix/relay_recipients:

# Addresses that forward to Google Workspace (no local alias)
[email protected]    OK

2. For domains with LDAP mailboxes (e.g., bluebird.co.za)

No changes needed to domain configuration. Addresses already exist in LDAP for recipient validation. Just add them to the transport map.

3. Configure transport map (IMPORTANT: Order matters!)

The transport map must be FIRST in the list so it's checked before LDAP transport lookups.

In /etc/postfix/main.cf:

transport_maps = hash:/etc/postfix/transport
        proxy:ldap:/etc/postfix/ldap-transport-exceptions.cf
        proxy:ldap:/etc/postfix/ldap-transport-reply.cf
        proxy:ldap:/etc/postfix/ldap-transport.cf

In /etc/postfix/transport:

# Forward to Google Workspace
# Only need bluebird.co.za addresses - aliases follow automatically
[email protected]         smtp:[aspmx.l.google.com]:25
[email protected]  smtp:[aspmx.l.google.com]:25

# For Workspace-only addresses (no local alias)
[email protected]        smtp:[aspmx.l.google.com]:25

# Existing entries for other routing...
hhsys.org       smtp:oregon.intelms.com
.hhsys.org      smtp:oregon.intelms.com
# ... etc

# NO CATCHALL! (see below)

IMPORTANT: Remove any * : catchall entry!

If you previously had * : at the end of the transport file, remove it. With the hash file now FIRST in transport_maps, a catchall would match everything and prevent LDAP lookups from ever being consulted.

Without catchall:

  1. Hash file checked → specific matches used
  2. No match → falls through to LDAP
  3. LDAP determines local delivery (dovecot:, bbprocmail:, error:, etc.)

4. Build hash files and reload

postmap /etc/postfix/relay_recipients
postmap /etc/postfix/transport
postfix reload

How It Works

Address Flow
[email protected] (has alias) Alias → [email protected] → transport map → Google
[email protected] (no alias) relay_recipient_maps OK → transport map → Google
[email protected] (neither) Rejected
[email protected] (in transport) LDAP validates → transport map → Google
[email protected] (not in transport) LDAP validates → LDAP transport → local delivery

Why relay_domains for intelms.com?

With virtual_mailbox_domains, Postfix requires every recipient to exist in either:

  • virtual_alias_maps (rewritten to another address)
  • virtual_mailbox_maps (local mailbox)

Addresses like [email protected] (Workspace-only, no local alias) have neither, so they'd be rejected.

With relay_domains, Postfix accepts addresses validated by relay_recipient_maps and routes them according to transport_maps.

Notes

  • Square brackets [aspmx.l.google.com] = use A record directly, skip MX lookup
  • Port 25 = standard SMTP (Google accepts server-to-server)
  • Skipping MX lookup is essential since YOUR server is the MX — without brackets, Postfix would loop
  • Transport map order matters — first match wins, so put the hash file before LDAP lookups
  • No catchall in hash file — a * : entry would prevent LDAP fallthrough

Testing

# Verify transport map entry
postmap -q "[email protected]" hash:/etc/postfix/transport
# Should return: smtp:[aspmx.l.google.com]:25

# Verify relay recipient (for intelms.com addresses without aliases)
postmap -q "[email protected]" hash:/etc/postfix/relay_recipients
# Should return: OK

# Send test and watch logs
echo "Test" | mail -s "Transport test" [email protected]
tail -f /var/log/mail.log

You should see Postfix connecting to aspmx.l.google.com instead of delivering locally.

Filtering by Original Recipient in Gmail

If multiple users' mail is forwarded to a single Workspace account (e.g., [email protected]), you'll need a way to filter/forward based on the original recipient.

The Problem

Gmail filters can only match on specific headers:

  • To/Cc/From/Subject
  • Message-ID
  • List-ID (mailing lists)
  • Delivered-To

The envelope recipient isn't directly filterable, and To/Cc won't contain Bcc addresses.

Solution: Add Delivered-To Header via Procmail

Gmail supports filtering on Delivered-To via deliveredto:[email protected] in filter criteria.

Use procmail to add the header before forwarding to Google:

1. Ensure procmail.sh passes RECIPIENT

The bbprocmail transport in master.cf calls a wrapper script:

bbprocmail unix -       n       n       -       16      pipe
  flags=DRqh user=virtual:virtual
  argv=/usr/local/scripts/procmail.sh ${nexthop} ${sender}

Important: The ${nexthop} is the mailbox path (e.g., bluebird.co.za/support7local), not the email address. This is because the transport map uses the path format for HOME directory lookup:

[email protected]  bbprocmail:bluebird.co.za/support7local

To get the actual email address for the Delivered-To header, choose one of these approaches:

Option A: Pass ${recipient} from Postfix (cleanest)

Modify master.cf to pass the recipient as a third argument:

bbprocmail unix -       n       n       -       16      pipe
  flags=DRqh user=virtual:virtual
  argv=/usr/local/scripts/procmail.sh ${nexthop} ${sender} ${recipient}

Then in procmail.sh:

#!/bin/sh
BASE=/virtual/mail

# Accept 2 args (backward compat) or 3 args (with recipient)
[ $# -ge 2 -a $# -le 3 ] || {
    logger -p mail.err -t procmail.sh "Bad args: $@"
    exit 64
}

HOME="${BASE}/$1"

cd "${HOME}" 2>/dev/null || {
    logger -p mail.err -t procmail.sh "Cannot change to ${HOME}. sender=$2"
    exit 64
}

logger -p mail.info -t procmail.sh "HOME=$HOME, recipient=$3, sender=$2"
/usr/bin/procmail RECIPIENT="$3" -m HOME=$HOME .procmailrc "$2"
exit $?

Option B: Derive email from mailbox path (no master.cf change)

Convert bluebird.co.za/support7local to [email protected] in the script:

#!/bin/sh
BASE=/virtual/mail

[ $# -eq 2 ] || {
    logger -p mail.err -t procmail.sh "Bad args: $@"
    exit 64
}

HOME="${BASE}/$1"

cd "${HOME}" 2>/dev/null || {
    logger -p mail.err -t procmail.sh "Cannot change to ${HOME}. sender=$2"
    exit 64
}

# Convert mailbox path (bluebird.co.za/support7local) to email ([email protected])
DOMAIN=$(echo "$1" | cut -d/ -f1)
USER=$(echo "$1" | cut -d/ -f2)
EMAIL_RECIPIENT="${USER}@${DOMAIN}"

logger -p mail.info -t procmail.sh "HOME=$HOME, recipient=$EMAIL_RECIPIENT, sender=$2"
/usr/bin/procmail RECIPIENT="$EMAIL_RECIPIENT" -m HOME=$HOME .procmailrc "$2"
exit $?

Note on positional arguments: The "$2" (sender) passed to procmail becomes $1 inside the .procmailrc. So:

  • $RECIPIENT = recipient email address (from variable assignment)
  • $1 = sender (from command line argument)

2. Create a procmail recipe (in the user's .procmailrc):

# Capture sender from positional arg (for envelope sender preservation)
SENDER="$1"

# Add Delivered-To header with original recipient
# Use -I to replace any existing Delivered-To header
:0 fhw
| formail -I "Delivered-To: $RECIPIENT"

# Forward to Google Workspace, preserving original envelope sender
# Using sendmail -f ensures bounces go to the original sender
:0
| /usr/sbin/sendmail -oi -f "$SENDER" [email protected]

Why sendmail instead of !? The -f "$SENDER" flag preserves the original envelope sender. Without this, bounces would go to [email protected] instead of the original sender.

3. Update transport map to route through procmail:

# In /etc/postfix/transport
# Route staff addresses through procmail (adds Delivered-To, then forwards)
# Note: The part after bbprocmail: is the mailbox path (domain/user), not email address
[email protected]         bbprocmail:bluebird.co.za/user
[email protected]  bbprocmail:bluebird.co.za/anotheruser

3. Create Gmail filters in the [email protected] account:

How It Works

  1. Mail arrives for [email protected]
  2. Transport map routes to bbprocmail:[email protected]
  3. Procmail adds Delivered-To: [email protected] header
  4. Procmail forwards to [email protected]
  5. Google receives mail with Delivered-To header intact
  6. Gmail filter matches deliveredto:[email protected]
  7. Filter forwards to user's personal Gmail

Alternative: Direct SMTP (No Filtering Needed)

If each user has their own Workspace account, skip procmail and route directly:

[email protected]    smtp:[aspmx.l.google.com]:25

The procmail approach is only needed when funneling multiple users through a single Workspace account.

Simplified Setup: LDAP-Based Forwarding (No Procmail)

A cleaner approach using LDAP attributes and native Postfix header handling — no procmail, no per-user config files.

How It Works

  1. LDAP entry has mailForwardingAddress: [email protected]
  2. New LDAP transport map matches and routes to gwsforward: transport
  3. Postfix pipe transport adds Delivered-To: header natively (via D flag)
  4. Message forwarded to Google Workspace

Configuration

1. Create /etc/postfix/ldap-transport-gws.cf:

server_host = localhost
version = 3
bind_dn = uid=root,ou=adminUsers,dc=frogfoot,dc=net
bind_pw = <password>
timeout = 80
search_base = ou=clients,dc=frogfoot,dc=net
query_filter = (&(objectClass=qmailUser)(uid=%s)([email protected]))
result_attribute = uid
result_format = gwsforward:
domain = bluebird.co.za

Note: result_attribute is required even though we don't use the value — Postfix LDAP won't return anything without it.

2. Add transport to /etc/postfix/master.cf:

gwsforward unix - n n - - pipe
  flags=DRhu user=virtual:virtual
  argv=/usr/sbin/sendmail -oi -f ${sender} [email protected]

The D flag tells Postfix to prepend Delivered-To: ${recipient} before passing to the pipe.

3. Update /etc/postfix/main.cf transport_maps:

Add the new LDAP config before other LDAP lookups:

transport_maps = proxy:ldap:/etc/postfix/ldap-transport-gws.cf
        hash:/etc/postfix/transport
        proxy:ldap:/etc/postfix/ldap-transport-exceptions.cf
        proxy:ldap:/etc/postfix/ldap-transport-reply.cf
        proxy:ldap:/etc/postfix/ldap-transport.cf

4. Prevent virtual alias from rewriting recipients first:

Edit /etc/postfix/ldap-virtual.cf to exclude GWS-forwarded addresses:

query_filter = (&(objectClass=qmailUser)(uid=%s)(deliveryMode=nolocal)(mailForwardingAddress=*)(!([email protected])))

The negative filter (!([email protected])) prevents the virtual alias from rewriting the recipient before the transport lookup runs.

5. Reload:

postfix reload

To Enable for a User

Add to LDAP entry:

mailForwardingAddress: [email protected]
deliveryMode: nolocal

That's it. No procmail.sh, no .procmailrc, no per-user config files.

Testing

# Verify LDAP query matches
postmap -q "[email protected]" ldap:/etc/postfix/ldap-transport-gws.cf
# Should return: gwsforward:

# Send test mail and check logs
echo "Test" | mail -s "GWS forward test" [email protected]
tail -f /var/log/mail.log
# Should show: relay=gwsforward

Comparison

Aspect Procmail Approach LDAP Approach
LDAP attributes needed 3 (deliveryProgramPath, qmailDotMode, deliveryMode) 2 (mailForwardingAddress, deliveryMode)
Per-user config files Yes (.procmailrc) No
External binaries procmail, formail None (native Postfix)
Delivered-To header formail -I Postfix D flag
Maintenance Per-user Centralized

SPF/DMARC Verification: It Works!

A common concern with forwarding setups is SPF failure. Testing confirms the inbound gateway + Gmail filter forwarding chain preserves authentication correctly.

Test Setup

Send a spoofed email through your mail server with a fake Received: header to simulate external mail. See spf_test_gist.py in this gist for the full script.

Run it from your mail server (the inbound gateway):

python2 spf_test_gist.py

The script:

  • Connects directly to aspmx.l.google.com
  • Adds a fake Received: header with a spoofed external IP
  • Adds a Delivered-To: header for Gmail filter matching
  • Uses different Header To vs envelope RCPT TO (realistic scenario)

Results

At [email protected] (Workspace):

Received-SPF: pass (google.com: domain of [email protected]
              designates 192.0.2.42 as permitted sender) client-ip=192.0.2.42

The inbound gateway correctly:

  1. Trusted camissa (the connecting IP)
  2. Parsed the Received: header to find the original sender IP
  3. Checked SPF against that IP, not camissa's

At personal Gmail (after filter forwarding):

ARC-Authentication-Results: i=3; mx.google.com;
   arc=pass (i=2 spf=pass spfdomain=lantic.net);
   spf=pass (google.com: domain of [email protected]
             designates 209.85.220.41 as permitted sender)
   dmarc=fail (p=NONE sp=NONE dis=NONE arc=pass) header.from=lantic.net

How It Works

ARC (Authenticated Received Chain) preserves authentication through forwarding:

  1. i=1: camissa → bbfilter — SPF pass for original sender (196.3.177.42)
  2. i=2: bbfilter stores original auth in ARC seal
  3. i=3: bbfilter → personal Gmail
    • SRS rewrite: Envelope sender becomes [email protected]
    • SPF passes for intelms.com against Google's IP (209.85.220.41)
    • DMARC fails (header From doesn't match envelope domain)
    • But ARC passes — the chain proves original authentication was valid
    • Message delivered because arc=pass overrides DMARC failure

Key Takeaway

The forwarding architecture is sound for authentication:

  • SPF passes at each hop (original sender, then SRS-rewritten sender)
  • DMARC may fail on final hop, but ARC preserves the trust chain
  • Gmail trusts its own ARC signatures through the forwarding path

Testing Strict DMARC Policies (p=reject)

A critical question: what happens when the original sender has p=reject? Will Gmail reject the message despite ARC?

Test: Spoof mail from linkedin.com (which has p=reject) using an IP from their SPF record. See spf_test_dmarc_reject.py in this gist.

Result at personal Gmail:

ARC-Authentication-Results: i=3; mx.google.com;
   arc=pass (i=2 spf=pass spfdomain=linkedin.com);
   spf=pass (google.com: domain of [email protected] ...)
   dmarc=fail (p=REJECT sp=REJECT dis=NONE arc=pass) header.from=linkedin.com

The key field is dis=NONE — Gmail took no action despite p=REJECT because arc=pass vouched for the original authentication. Message delivered successfully.

Conclusion: ARC overrides even the strictest DMARC policies when the chain is trusted.

Gmail UI vs Raw Headers

Gmail's "Show original" UI displays DMARC results that may seem inconsistent:

Sender Domain DMARC Record Raw Header Result Gmail UI Shows
linkedin.com p=reject dmarc=fail ... arc=pass PASS
bluebird.co.za (before) (none) No DMARC check FAIL
bluebird.co.za (after) p=none dmarc=pass PASS

Why? Gmail's UI shows the effective authentication status:

  • DMARC exists + fails + ARC passes → "PASS" (ARC vouched for it)
  • No DMARC record → "FAIL" (nothing to validate against)

The raw headers always tell the true technical story. The UI simplifies for end users.

Adding DMARC Records

Add a DMARC record to fix the Gmail UI display and improve deliverability signals.

DNS Record (e.g., in AWS Route53):

Field Value
Name _dmarc
Type TXT
Value v=DMARC1; p=none
TTL 3600 (or default)

What DMARC does:

  • p=none = monitoring mode, no mail rejected
  • Tells receivers "I care about authentication" (deliverability signal)
  • Fixes Gmail UI showing "FAIL" for domains without DMARC
  • Later you can tighten to p=quarantine then p=reject

Optional reporting — receivers send daily XML reports about your domain's mail:

v=DMARC1; p=none; rua=mailto:[email protected]

Reports are gzipped XML, not human-friendly. Services like Postmark DMARC (free) can parse them into dashboards.

Verify:

dig +short TXT _dmarc.yourdomain.com
# Should return: "v=DMARC1; p=none"

Rule of thumb: Any domain with MX or SPF records should also have DMARC.

Status: All domains now have DMARC records:

  • ✓ bluebird.co.za
  • ✓ intelms.com
  • ✓ intelms.com.au
  • ✓ intelms.ca
  • ✓ radiology.co.za
  • ✓ gynecology.co.za
  • ✓ bluebirdims.com
  • ✓ bluesanit.com
  • ✓ bbehraas.com

Known Limitation: Forwarded Mail Arrives as "Read" (False Alarm!)

Update: Initial testing suggested messages arrived as "read" in personal Gmail. This turned out to be caused by an old filter in the test mailbox that was marking everything as read. The forwarding setup works correctly — messages arrive unread.

If you experience this issue, check for filters in the destination Gmail account that might be marking messages as read.

Why Go Through Workspace At All?

Forwarding directly from the mail server to personal Gmail breaks SPF:

mail server → personal Gmail
     ↑
  SPF FAIL (server not authorized to send for original sender's domain)

The Workspace inbound gateway configuration tells Gmail to trust the mail server as a forwarder and check SPF against the original sender. Personal Gmail doesn't have inbound gateway settings — that's a Workspace admin feature.

So the flow must go through Workspace:

mail server → Workspace (gateway trust) → filter forward → personal Gmail

This preserves authentication via ARC and ensures reliable delivery.

#!/usr/bin/env python2
"""
Test SPF/DMARC with a p=reject sender domain.
Tests whether ARC preserves authentication through forwarding when
the original sender has a strict DMARC policy.
Run FROM your mail server (the inbound gateway).
Usage:
python2 spf_test_dmarc_reject.py
"""
import smtplib
from email.mime.text import MIMEText
import socket
import uuid
from datetime import datetime
# Configuration
SMTP_SERVER = 'aspmx.l.google.com'
SMTP_PORT = 25
# LinkedIn has p=reject DMARC policy
# Their SPF includes ip4:108.174.3.0/24
ENVELOPE_FROM = '[email protected]'
HEADER_FROM = 'LinkedIn Test <[email protected]>'
# Header To (display only - doesn't affect routing)
HEADER_TO = 'Recipient Name <[email protected]>'
# Envelope recipient - where the mail actually goes
RCPT_TO = '[email protected]'
# Delivered-To header - used for Gmail filter matching (deliveredto: search)
DELIVERED_TO = '[email protected]'
# Fake external IP - must be in sender's SPF for SPF to pass
# LinkedIn SPF: ip4:108.174.3.0/24
FAKE_EXTERNAL_IP = '108.174.3.42'
FAKE_EXTERNAL_HOST = 'mail.linkedin.com'
# Build message
msg = MIMEText(
'DMARC p=reject test\n'
'Sender: %s (has p=reject)\n'
'Spoofed IP: %s (in LinkedIn SPF)\n'
'Delivered-To: %s\n\n'
'Expected:\n'
'- SPF PASS at bbfilter (IP in LinkedIn SPF)\n'
'- DMARC FAIL at personal Gmail (envelope rewritten by SRS)\n'
'- ARC PASS should override the reject policy\n'
'- Message should be delivered despite p=reject'
% (ENVELOPE_FROM, FAKE_EXTERNAL_IP, DELIVERED_TO)
)
msg['Subject'] = 'DMARC p=reject Test - %s' % ENVELOPE_FROM
msg['From'] = HEADER_FROM
msg['To'] = HEADER_TO
msg['Message-ID'] = '<%s@%s>' % (uuid.uuid4(), FAKE_EXTERNAL_HOST)
# Prepend headers: Delivered-To first, then fake Received
timestamp = datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S +0000 (UTC)')
fake_received = (
'from %s (%s [%s])\r\n'
'\tby camissa.bluebird.co.za (Postfix) with ESMTP id FAKE%s\r\n'
'\tfor <%s>; %s'
% (FAKE_EXTERNAL_HOST, FAKE_EXTERNAL_HOST, FAKE_EXTERNAL_IP,
uuid.uuid4().hex[:8].upper(), RCPT_TO, timestamp)
)
raw_msg = 'Delivered-To: %s\r\nReceived: %s\r\n%s' % (
DELIVERED_TO, fake_received, msg.as_string()
)
print 'DMARC p=reject Test'
print '=' * 50
print 'Sender domain: linkedin.com (p=reject)'
print 'Fake origin: %s [%s]' % (FAKE_EXTERNAL_HOST, FAKE_EXTERNAL_IP)
print 'MAIL FROM: <%s>' % ENVELOPE_FROM
print 'RCPT TO: <%s>' % RCPT_TO
print 'Delivered-To: %s' % DELIVERED_TO
print
print 'Connecting to %s:%d...' % (SMTP_SERVER, SMTP_PORT)
socket.setdefaulttimeout(30)
smtp = smtplib.SMTP(SMTP_SERVER, SMTP_PORT)
smtp.set_debuglevel(1)
print '\nSending EHLO as camissa...'
smtp.ehlo('camissa.bluebird.co.za')
print '\nSending mail...'
try:
smtp.sendmail(ENVELOPE_FROM, [RCPT_TO], raw_msg)
print '\n' + '=' * 50
print 'SENT!'
print
print 'Check the message in personal Gmail and look for:'
print ' - Authentication-Results showing arc=pass'
print ' - DMARC result: dmarc=fail (p=REJECT ... dis=NONE arc=pass)'
print ' - dis=NONE means no action taken despite p=reject'
print ' - Message delivered (not rejected)'
except smtplib.SMTPRecipientsRefused, e:
print '\nRecipient refused: %s' % e
except smtplib.SMTPSenderRefused, e:
print '\nSender refused: %s' % e
smtp.quit()
#!/usr/bin/env python2
"""
Test SPF via Inbound Gateway by faking a Received header.
Simulates external mail flowing through your mail server to Google Workspace.
Run this FROM your mail server (the inbound gateway) to test SPF handling.
Usage:
python2 spf_test.py
"""
import smtplib
from email.mime.text import MIMEText
import socket
import uuid
from datetime import datetime
# Configuration
SMTP_SERVER = 'aspmx.l.google.com'
SMTP_PORT = 25
# Envelope sender - this is what SPF checks against
# Use an address whose SPF record includes the FAKE_EXTERNAL_IP below
ENVELOPE_FROM = '[email protected]'
# Header From (what recipient sees in their mail client)
HEADER_FROM = 'Sender Name <[email protected]>'
# Header To (display only - doesn't affect routing)
HEADER_TO = 'Recipient Name <[email protected]>'
# Envelope recipient - where the mail actually goes
RCPT_TO = '[email protected]'
# Delivered-To header - used for Gmail filter matching (deliveredto: search)
DELIVERED_TO = '[email protected]'
# Fake external IP - must be in ENVELOPE_FROM domain's SPF record for SPF to pass
# Example: For lantic.net, use an IP from Vox's SPF (196.3.177.0/24)
FAKE_EXTERNAL_IP = '192.0.2.42'
FAKE_EXTERNAL_HOST = 'mail.example.com'
# Build message
msg = MIMEText(
'SPF test - spoofing %s from IP %s\n'
'Delivered-To: %s\n'
'Should PASS SPF if IP is in sender domain\'s SPF record.'
% (ENVELOPE_FROM, FAKE_EXTERNAL_IP, DELIVERED_TO)
)
msg['Subject'] = 'SPF Forward Test - %s via %s' % (ENVELOPE_FROM, FAKE_EXTERNAL_IP)
msg['From'] = HEADER_FROM
msg['To'] = HEADER_TO
msg['Message-ID'] = '<%s@%s>' % (uuid.uuid4(), FAKE_EXTERNAL_HOST)
# Prepend headers: Delivered-To first, then fake Received
# Format matches what Postfix generates
timestamp = datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S +0000 (UTC)')
fake_received = (
'from %s (%s [%s])\r\n'
'\tby camissa.bluebird.co.za (Postfix) with ESMTP id FAKE12345\r\n'
'\tfor <%s>; %s'
% (FAKE_EXTERNAL_HOST, FAKE_EXTERNAL_HOST, FAKE_EXTERNAL_IP, RCPT_TO, timestamp)
)
# Build raw message with custom headers prepended
raw_msg = 'Delivered-To: %s\r\nReceived: %s\r\n%s' % (
DELIVERED_TO, fake_received, msg.as_string()
)
print 'Connecting to %s:%d...' % (SMTP_SERVER, SMTP_PORT)
socket.setdefaulttimeout(30)
smtp = smtplib.SMTP(SMTP_SERVER, SMTP_PORT)
smtp.set_debuglevel(1)
print '\nSending EHLO as camissa...'
smtp.ehlo('camissa.bluebird.co.za')
print '\nSending mail with fake Received header...'
print ' Fake origin: %s [%s]' % (FAKE_EXTERNAL_HOST, FAKE_EXTERNAL_IP)
print ' MAIL FROM: <%s>' % ENVELOPE_FROM
print ' RCPT TO: <%s>' % RCPT_TO
print ' Delivered-To: %s' % DELIVERED_TO
print ' Header To: %s' % HEADER_TO
try:
smtp.sendmail(ENVELOPE_FROM, [RCPT_TO], raw_msg)
print '\nSent! Check Authentication-Results header in received message.'
except smtplib.SMTPRecipientsRefused, e:
print '\nRecipient refused: %s' % e
except smtplib.SMTPSenderRefused, e:
print '\nSender refused: %s' % e
smtp.quit()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment