Professional, modular PowerShell email system for sending HTML emails with inline images, attachments, and flexible authentication (Basic Auth / OAuth2).
- Modular Architecture: Separate modules for authentication, template processing, and sending
- Dual Authentication: Support for both Basic Auth and OAuth2 (Client Credentials Flow)
- Inline Images: Embedded images using AlternateView and LinkedResources
- Batch Processing: Rate-limited sending with configurable batch windows
- Resume Capability: Checkpoint-based system to resume interrupted campaigns
- Retry Logic: Automatic retry with exponential backoff
- Template System: HTML templates with placeholder replacement
- Security: HTML encoding to prevent injection attacks
- Attachments: Support for multiple PDF/file attachments
M365DigestEmailModule.psm1 # Core PowerShell module
M365_Digest_Template.htm # HTML email template
Send-M365Digest-BasicAuth.ps1 # Example: Basic Authentication
Send-M365Digest-OAuth.ps1 # Example: OAuth2 Authentication
README.md # This file
- PowerShell 5.1 or higher
- .NET Framework 4.5+
- SMTP access to Office 365 (smtp.office365.com:587)
- For OAuth: Azure AD App Registration with Mail.Send permissions
# 1. Import the module
Import-Module .\M365DigestEmailModule.psm1
# 2. Configure and run
.\Send-M365Digest-BasicAuth.ps1 -ConfigMode Test# 1. Set up Azure AD App Registration (see OAuth Setup below)
# 2. Update OAuth configuration in Send-M365Digest-OAuth.ps1
# 3. Run the script
.\Send-M365Digest-OAuth.ps1 -ConfigMode Production- Navigate to Azure Portal β Azure Active Directory β App registrations
- Click New registration
- Name:
M365-Digest-Email-Sender - Supported account types: Accounts in this organizational directory only
- Click Register
- Go to API permissions blade
- Click Add a permission
- Select Microsoft Graph β Application permissions
- Add:
Mail.Send - Click Grant admin consent for [Your Organization]
- Go to Certificates & secrets blade
- Click New client secret
- Description:
M365 Digest SMTP - Expires: Select appropriate expiration (e.g., 24 months)
- Click Add
- IMPORTANT: Copy the secret value immediately (can't retrieve later)
From the Overview blade, note:
- Application (client) ID:
12345678-1234-1234-1234-123456789abc - Directory (tenant) ID:
87654321-4321-4321-4321-cba987654321 - Client Secret: From Step 3
Edit Send-M365Digest-OAuth.ps1:
$oauthConfig = @{
TenantId = "87654321-4321-4321-4321-cba987654321"
ClientId = "12345678-1234-1234-1234-123456789abc"
ClientSecret = "your_client_secret_here"
Username = "sender@yourdomain.com"
}The template uses simple placeholder replacement:
<!-- In M365_Digest_Template.htm -->
<div>CARD1_TITLE</div>
<div>CARD1_CONTENT</div>
<a href="CARD1_LINK">Link</a>
<!-- Inline images use CID references -->
<img src="cid:m365_icon" alt="M365" width="100">Configure in your script:
$replacements = @{
'CARD1_TITLE' = "New Feature Announcement"
'CARD1_CONTENT' = "Detailed description here..."
'CARD1_LINK' = "https://admin.microsoft.com/..."
'CARD2_TITLE' = "Another Update"
# ... etc
}Define images in your script:
$inlineImages = @(
@{
ContentId = 'datagroup_logo' # Must match cid: in template
FilePath = 'C:\temp\logo.png'
},
@{
ContentId = 'm365_icon'
FilePath = 'C:\temp\m365.png'
}
)In HTML template:
<img src="cid:datagroup_logo" alt="Logo" width="130">
<img src="cid:m365_icon" alt="M365" width="100">Expected CSV structure (semicolon-separated):
email;DisplayName_email;password;secret_link
user1@example.com;John Doe;Pass123;https://link1
user2@example.com;Jane Smith;Pass456;https://link2Customize column mapping in the script:
$csvData = Import-Csv -LiteralPath $csvPath -Delimiter ';' -Encoding UTF8
foreach ($row in $csvData) {
$email = $row.email
$displayName = $row.DisplayName_email
# Build replacements based on your CSV columns
}$batchConfig = @{
BatchSize = 20 # Emails per batch window
WindowMinutes = 3.0 # Minutes between batches
MaxRetries = 3 # Retry attempts per email
}$smtpConfig = @{
Server = "smtp.office365.com"
Port = 587
EnableSsl = $true
From = "sender@example.com"
Bcc = "admin@example.com" # Optional
Subject = "Your Email Subject"
}The system uses checkpoint files to track sent emails:
$checkpointFile = "C:\Temp\email_checkpoint.txt"If sending is interrupted:
- The checkpoint file contains all successfully sent emails
- Re-run the script - it will skip already-sent addresses
- Sending resumes from where it left off
To restart from scratch: Delete the checkpoint file
Use Test mode for initial testing:
.\Send-M365Digest-BasicAuth.ps1 -ConfigMode TestTest mode changes:
- BatchSize = 2 (sends only 2 emails per batch)
- WindowMinutes = 0.1 (6 seconds between batches)
- Faster execution for validation
Never hardcode passwords in production scripts!
# Save credential once
$cred = Get-Credential
$cred.Password | ConvertFrom-SecureString | Set-Content "C:\secure\smtp.txt"
# Load in script
$securePassword = Get-Content "C:\secure\smtp.txt" | ConvertTo-SecureString# Requires Az.KeyVault module
$secret = Get-AzKeyVaultSecret -VaultName "MyVault" -Name "SMTPPassword"
$password = $secret.SecretValueText# Requires CredentialManager module
$cred = Get-StoredCredential -Target "M365-SMTP"- Store Client Secrets in Azure Key Vault
- Use Managed Identities when running from Azure
- Rotate secrets every 6-12 months
- Apply principle of least privilege (Mail.Send only)
The module provides colored console output:
β Green = Success
β Yellow = Warning
β Red = Error
Add file logging:
# Add to your script
$logFile = "C:\Logs\email-campaign-$(Get-Date -Format 'yyyyMMdd-HHmmss').log"
Start-Transcript -Path $logFile
# ... run campaign ...
Stop-TranscriptError: 5.7.57 SMTP; Client was not authenticated
Solution:
- Verify username/password
- Enable "SMTP AUTH" in Exchange admin center
- Check if Modern Auth is required
Error: Failed to acquire OAuth token
Solution:
- Verify TenantId, ClientId, ClientSecret
- Ensure Mail.Send permission is granted
- Confirm admin consent is completed
- Check token endpoint:
https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token
Solution:
- Verify CID in template matches ContentId in config
- Use exact CID format:
<img src="cid:image_name"> - Check file paths exist
- Ensure image files are valid (PNG, JPG)
Error: 4.3.2 Service too busy
Solution:
- Increase WindowMinutes (try 5-10 minutes)
- Reduce BatchSize (try 10-15 emails)
- Add random jitter between sends
Creates credential object for SMTP authentication.
# Basic Auth
$cred = Get-EmailAuthenticationCredential `
-AuthMethod 'Basic' `
-Username 'user@example.com' `
-Password 'SecurePass123'
# OAuth2
$cred = Get-EmailAuthenticationCredential `
-AuthMethod 'OAuth' `
-Username 'user@example.com' `
-TenantId '...' `
-ClientId '...' `
-ClientSecret '...'Loads HTML template and replaces placeholders.
$html = Get-ProcessedHtmlTemplate `
-TemplatePath 'C:\temp\template.htm' `
-Replacements @{
'TITLE' = 'Hello World'
'CONTENT' = 'Email body text'
}Creates AlternateView with embedded inline images.
$altView = New-EmailAlternateViewWithImages `
-HtmlBody $htmlContent `
-InlineImages @(
@{ ContentId = 'logo'; FilePath = 'C:\logo.png' }
)Sends single HTML email with inline images and attachments.
Send-HtmlEmail `
-To 'recipient@example.com' `
-From 'sender@example.com' `
-Subject 'Test Email' `
-HtmlBody $html `
-InlineImages $images `
-Attachments @('C:\file.pdf') `
-SmtpServer 'smtp.office365.com' `
-SmtpPort 587 `
-Credential $credSends bulk emails with batching, rate limiting, and checkpointing.
Send-BulkHtmlEmail `
-Recipients $recipients `
-TemplateConfig $templateConfig `
-SmtpConfig $smtpConfig `
-BatchSize 20 `
-WindowMinutes 3.0 `
-CheckpointPath 'C:\checkpoint.txt'foreach ($row in $csvData) {
# Personalize based on user data
$replacements = @{
'GREETING' = "Hello $($row.FirstName),"
'CONTENT' = "Your account expires on $($row.ExpiryDate)"
}
$recipients += [PSCustomObject]@{
Email = $row.email
Replacements = $replacements
}
}# Show different content based on user type
if ($row.UserType -eq 'Premium') {
$replacements['CARD3_TITLE'] = 'Premium Features'
$replacements['CARD3_CONTENT'] = 'Exclusive content...'
} else {
$replacements['CARD3_TITLE'] = 'Upgrade Today'
$replacements['CARD3_CONTENT'] = 'Get premium features...'
}This code is provided as-is for use within your organization. Adapt and modify as needed.
For issues or questions:
- Check troubleshooting section
- Review Office 365 SMTP documentation
- Verify Azure AD app permissions (for OAuth)
- Test with a single recipient first
- v1.0.0 (2025-11-07): Initial release
- Basic and OAuth authentication
- Inline images with AlternateView
- Batch processing with checkpoints
- Modular architecture
Built with precision for Microsoft 365 enterprise email campaigns.