Payton Flint's Tech Blog
Menu
  • Home
  • Blog
  • Categories
  • Resources
  • About
  • Contact
Menu

Cloud – Enterprise Gmail Mass-Mailer

Posted on September 16, 2024November 8, 2024 by paytonflint

If you’ve ever needed to perform mass-mailing operations within an enterprise, you’ll know there are a lot of considerations. Being able to bypass filters and rate limits for different platforms may be necessary to send messages in a timely manner, or to avoid bigger problems. So, if you are using Google Workspace for email, why not go straight to the source?

The Gmail API provides an “Insert” method that can be used to insert an unread message straight into a user’s Inbox: https://developers.google.com/gmail/api/reference/rest/v1/users.messages/insert. The insert method is advantageous because it is not limited to per-day rate limits of mailboxes. It is literally placing the email into the user’s inbox directly through the API and is not a send.

This script requires some prerequisite setup. Go to the Cloud console (https://console.cloud.google.com/) and set up a new project. Then, use the hamburger icon to navigate to the API Library; then, enable the Gmail API. The hamburger icon can then bring you to the Service Accounts menu. Configure a new service account, and make it the owner of this project. Once configured, add a JSON key for the service account. Be sure to save this file to a secure location. Be sure to set up domain-wide delegation for this service account so that it can execute for each user in your organization. In the domain-wide delegation configuration, you will also configure your OAuth scopes. In this case, we only require the “https://www.googleapis.com/auth/gmail.insert” scope.

It is important to understand that we will be generating a token that can be used with the Gmail API insert method for each user. Then, we pass the token to a function that is posting the email with the insert method, as discussed previously. I’ve set things up to pull your HTML-formatted email in from another file, to keep things orderly. For some of the cryptographic functionality pertaining to the creation of the token, this script configures NuGet as a trusted repository, and then retrieves a BouncyCastle .DLL file that is loaded into the session to add additional .NET types that are used in cryptographic operations.

Your user list can be specified as a .CSV file, and this script is configured to write the message ID back to the .CSV for future reference. I am using this script to insert an email notification to over 36,000 mailboxes. Here is a link to my script: https://github.com/p8nflnt/Cloud-Toolbox/blob/main/Google/Send-MassGmail.ps1. And here is the source code:

<#
.SYNOPSIS
    Insert an unread email into the Gmail inbox for each user specified in a .CSV file using a service account

.NOTES
    Name: Add-GmailMessageForList
    Author: Payton Flint
    Version: 1.0
    DateCreated: 2024-Oct

.LINK
    https://github.com/p8nflnt/Cloud-Toolbox/blob/main/google/Send-MassGmail.ps1
    https://paytonflint.com/cloud-enterprise-gmail-mass-mailer/
#>

# specify variables
$from        = "<SENDER EMAIL>"    # Sender of email - Ex. [email protected]
$to          = "<RECIPIENT EMAIL>" # Recipient of email
$subject     = "<EMAIL SUBJECT>"   # Email subject line
$htmlMsgBody = "<EMAIL BODY HTML>" # Path to file containing HTML message body
$keyFilePath = "<JSON KEY FILE>"   # Path to service account .JSON key file
$userListCsv = "<USER LIST CSV>"   # Path to file containing list of userList

Function Test-ElevatedShell {
    # Check if the current user has administrative privileges
    $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
    
    if ($isAdmin) {
        Write-Host "You are running this script with administrator privileges."
        return $true
    } else {
        Write-Warning "You are not running this script with administrator privileges. Please restart the script as an administrator."
        return $false
    }
}

Function Add-NuGet {
    $package = Get-PackageSource -Name 'Nuget' -ErrorAction SilentlyContinue

    if ($package.IsTrusted -eq $False) {
        Write-Host "NuGet is installed, but is not trusted."
        Write-Host "Setting NuGet as trusted source."
        Set-PackageSource -Name 'Nuget' -Trusted -Force
    } elseif ($package -eq $null) {
        Write-Host "NuGet is not currently a registered source."
        Write-Host "Registering NuGet as trusted source."
        Register-PackageSource -Name Nuget -Location "https://www.nuget.org/api/v2" –ProviderName Nuget -Trusted -Force
    } else {
        Write-Host "NuGet is currently registered as a trusted source."
    }
}

Function Install-BouncyCastle {
    # Retrieve installed package information
    $bouncyCastle = Get-Package BouncyCastle -ErrorAction SilentlyContinue

    # If BouncyCastle package is not present...
    If (!($bouncyCastle)) {
        Write-Host "BouncyCastle not found, downloading..."

        # Install BouncyCastle package for cryptographic processing
        Install-Package BouncyCastle -ErrorAction SilentlyContinue
    } Else {
        # Locate BouncyCastle .DLL file 
        $bouncyCastle = $bouncyCastle.Source | Split-Path
        $bouncyCastle = $(Get-ChildItem -Path $bouncyCastle -Recurse -Filter *.dll | Select-Object -First 1).FullName
    }

    # If BouncyCastle .DLL was found...
    If ($bouncyCastle) {
        Write-Host "BouncyCastle present, loading assembly to current session..."
        Add-Type -Path $bouncyCastle -ErrorAction Stop
        Write-Host "BouncyCastle assembly loaded into the current session successfully."
    } Else {
        Write-Host "No BouncyCastle .DLL file found."
    }
    # Return .DLL file path for reference post-install
    #return $bouncyCastle
}

Function Get-GoogleAccessToken {
    param (
        [string]$scope,       # OAuth permission scope(s) - multiple scopes should be space-separated
        [string]$keyFilePath, # Path to service account key file path
        [string]$user,        # Subject - Email of the user to impersonate
        [int]$ttl             # Token time-to-live in seconds (3600 default)
    )
  
    # If expiration not specified, set default
    If (!($ttl)) {
        [int]$ttl = 3600
    }
  
    # Get client_email from JSON key file
    $jsonContent = Get-Content -Raw -Path $keyFilePath | ConvertFrom-Json
    $svcAcct     = $jsonContent.client_email
  
    # JWT Header
    $header = @{
        alg = "RS256"
        typ = "JWT"
    } | ConvertTo-Json | Out-String
  
    # JWT Payload
    $now = [int](Get-Date -Date (Get-Date).ToUniversalTime() -UFormat %s)
    $exp = $now + $ttl # Token expiration
    $payload = @{
        iss   = $svcAcct 
        scope = $scope # OAuth permission scope(s)
        aud   = "https://oauth2.googleapis.com/token" # Audience
        sub   = $user # Email of the user to impersonate
        iat   = [math]::floor((Get-Date).ToUniversalTime().Subtract([datetime]'1970-01-01').TotalSeconds)
        exp   = [math]::floor((Get-Date).ToUniversalTime().AddHours(1).Subtract([datetime]'1970-01-01').TotalSeconds)
    } | ConvertTo-Json -Compress
  
    # Function for Base64 URL-safe encoding
    function Encode-UrlBase64 {
        param([byte[]]$inputBytes)
        $base64 = [Convert]::ToBase64String($inputBytes).TrimEnd('=')
        $base64 = $base64.Replace('+', '-').Replace('/', '_')
        return $base64
    }
  
    # Convert Header and Payload to Base64
    $headerBase64 = Encode-UrlBase64 -inputBytes ([System.Text.Encoding]::UTF8.GetBytes($header))
    $payloadBase64 = Encode-UrlBase64 -inputBytes ([System.Text.Encoding]::UTF8.GetBytes($payload))
  
    # Extract private key from JSON file
    $pvtKeyString = $jsonContent.private_key -replace "-----BEGIN PRIVATE KEY-----", "" -replace "-----END PRIVATE KEY-----", "" -replace "\s+", ""
    $pvtKeyBytes = [Convert]::FromBase64String($pvtKeyString)
  
    # Convert the private key into an RSA key using BouncyCastle's PrivateKeyFactory
    $pvtKeyInfo = [Org.BouncyCastle.Asn1.Pkcs.PrivateKeyInfo]::GetInstance($pvtKeyBytes)
    $pvtKey = [Org.BouncyCastle.Security.PrivateKeyFactory]::CreateKey($pvtKeyInfo)
  
    # Create the signer object for RSA/SHA256
    $signer = New-Object Org.BouncyCastle.Crypto.Signers.RsaDigestSigner ([Org.BouncyCastle.Crypto.Digests.Sha256Digest]::new())
    $signer.Init($true, $pvtKey)
  
    # Create the unsigned JWT
    $unsignedJwt = "$headerBase64.$payloadBase64"
  
    # Sign the JWT
    $signer.BlockUpdate([System.Text.Encoding]::UTF8.GetBytes($unsignedJwt), 0, $unsignedJwt.Length)
    $signature = $signer.GenerateSignature()
  
    # Convert signature to URL-safe base64
    $signatureBase64 = Encode-UrlBase64 -inputBytes $signature
    $jwt = "$unsignedJwt.$signatureBase64"
  
    # Exchange the JWT for an access token
    $requestUri = "https://oauth2.googleapis.com/token"
    $body = @{
        grant_type = "urn:ietf:params:oauth:grant-type:jwt-bearer"
        assertion  = $jwt
    }
  
    # POST JWT for access token
    $response = Invoke-RestMethod -Uri $requestUri -Method POST -Body $body -ContentType "application/x-www-form-urlencoded"
  
    # Output the access token
    return $response.access_token  
}

# Insert an unread email into user's Gmail inbox
Function Insert-GmailMessage {
    param (
        [string]$token,      # Google access token
        [string]$from,       # Sender of email
        [string]$to,         # Recipient of email
        [string]$subject,    # Email subject line
        [string]$htmlMsgBody # Path to file containing HTML message body
    )

    # Get HTML message body contents
    $htmlMsgBody = Get-Content -Path $htmlMsgBody

    # Derive the email domain
    $emailDomain = '@' + ($from -split "@")[1]
    # Build Message-ID
    $newGuid = $(New-Guid).ToString()  # Convert GUID to string
    # Concatenate GUID and email domain
    $messageId = '<' + $newGuid + $emailDomain + '>'

    # Create the email message (RFC 5322 format)
    $emailContent = 
@"
From: $from
To: $to
Subject: $subject
Date: $(Get-Date -Format "ddd, dd MMM yyyy HH:mm:ss zzz")
Message-ID: $messageId
Content-Type: text/html; charset="UTF-8"

$htmlMsgBody
"@


    # Encode the email message in Base64 URL-safe format
    $emailBytes = [System.Text.Encoding]::UTF8.GetBytes($emailContent)
    $encodedEmail = [Convert]::ToBase64String($emailBytes)
    $encodedEmail = $encodedEmail -replace '\+', '-' -replace '\/', '_' -replace '=', ''

    # Insert the message into Gmail using the Gmail API (using URI for without media upload)
    $insertUri = "https://gmail.googleapis.com/gmail/v1/users/me/messages"

    # Define the JSON message body
    $body = @{
        raw = $encodedEmail
        labelIds = @("INBOX", "UNREAD")  # Adds the message as unread to the inbox
    } | ConvertTo-Json

    # Send POST to API to add message to user's inbox
    $response = Invoke-RestMethod -Uri $insertUri -Method POST -Body $body -ContentType "application/json" -Headers @{
        Authorization = "Bearer $accessToken"
    }

    # Output result
    if ($response.id) {
        Write-Host "Message inserted with ID: $($response.id)"
        return $response.id
    } else {
        Write-Host "Failed to insert message."
    }
}

# Only proceed if executed with elevated privileges
if (Test-ElevatedShell) {

    # Add NuGet repository if it is not already configured
    Add-NuGet

    # Install BouncyCastle .DLL for cryptographic processing
    Install-Package BouncyCastle

    # Install BouncyCastle .DLL and get path for reference
    Install-BouncyCastle

    # Provide scope for token creation in Get-GoogleAccessToken function
    $scope = "https://www.googleapis.com/auth/gmail.insert"

    # Retrieve user list contents
    $userList = Import-Csv -Path $userListCsv

    # Get each user in list
    ForEach ($user in $userList) {

        # Initialize variables for loop
        $accessToken = $null
        $messageId   = $null

        # Get access token from Google
        $accessToken = Get-GoogleAccessToken -scope $scope -keyFilePath $keyFilePath -user $user.email

        # Insert unread email into user's Gmail inbox
        $messageId = Insert-GmailMessage -token $accessToken -from $from -to $user.email -subject $subject -htmlMsgBody $htmlMsgBody
        
        # Add message id to .CSV
        $user | Add-Member -MemberType NoteProperty -Name "MessageId" -Value $messageId -Force
    }

    # Export the updated list back to the CSV (overwrite the original file)
    $userList | Export-Csv -Path $userListCsv -NoTypeInformation
}

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

About The Author

Author's Portrait

In my journey as a technologist and 11 years of experience as an IT professional, I have found my niche as Director of Infrastructure Services; developing my skillsets in management, scripting, cloud infrastructure, identity management, and networking.

I have experience as a Systems Administrator and Engineer for large enterprises including the DoD, government agencies, and a nuclear-generation site.

I've been blessed to collaborate with engineers at esteemed Fortune 50 corporations, and one of Africa's largest, to ensure successful implementation of my work.

GitHub Button

Credentials

M365 Endpoint Administrator Associate
M365 Fundamentals
Microsoft AZ-900
CompTIA CSIS
CompTIA CIOS
CompTIA Security+
CompTIA Network+
CompTIA A+
  • April 2025
  • December 2024
  • November 2024
  • October 2024
  • September 2024
  • August 2024
  • May 2024
  • April 2024
  • March 2024
  • February 2024
  • January 2024
  • December 2023
  • November 2023
  • October 2023
  • September 2023
  • August 2023
  • July 2023
  • June 2023
  • May 2023
  • April 2023
  • March 2023
  • February 2023
  • January 2023
  • December 2022
  • November 2022
  • October 2022
  • September 2022
  • August 2022
© 2022 Payton Flint | The views and opinions expressed on this website belong solely to the author/owner and do not represent the perspectives of any individuals, institutions, or organizations, whether affiliated personally or professionally, unless explicitly stated otherwise. The content and products on this website are provided as-is with no warranties or guaranties, are for informational/demonstrative purposes only, do not constitute professional advice, and are not to be used maliciously. The author/owner is not responsible for any consequences arising from actions taken based on information provided on this website, nor from the use/misuse of products from this site. All trademarks are the property of their respective owners.