Public/Send-Mail.ps1

<#
.SYNOPSIS
Sends emails using an SMTP server.

.DESCRIPTION
The `Send-Mail` function sends emails with HTML support, configured via environment variables
or explicit parameters. Ideal for automation scripts, CI/CD pipelines, and scheduled tasks.

.PARAMETER Subject
The email subject. This parameter is required.

.PARAMETER Body
The email body content. This parameter is required. It can also come from the pipeline:
piped lines (e.g. from Get-Content) are joined into a single body and sent as one email.

.PARAMETER IsHtml
Indicates whether the body is HTML formatted. Default is $False.

.PARAMETER FromName
The sender display name. If not provided, uses the SMTP_FROM_NAME environment variable
or, failing that, the machine name ([Environment]::MachineName).

.PARAMETER To
One or more recipient email addresses. If not provided, uses the SMTP_TO environment variable.
Each entry may also be a comma-separated list of addresses.

.PARAMETER Cc
One or more carbon copy addresses. If not provided, uses the SMTP_CC environment variable.

.PARAMETER Bcc
One or more blind carbon copy addresses. If not provided, uses the SMTP_BCC environment variable.

.PARAMETER Priority
The email priority: Low, Normal or High. Default is Normal.

.PARAMETER Attachments
Array of file paths to attach to the email. Files must exist on the filesystem.

.NOTES
SMTP credentials and settings must be defined via environment variables:
- SMTP_USER (required)
- SMTP_PASS (required)
- SMTP_SERVER (optional, default: smtp.gmail.com)
- SMTP_PORT (optional, default: 587)
- SMTP_FROM (optional, default: SMTP_USER)
- SMTP_FROM_NAME (optional, default: machine name)
- SMTP_TO, SMTP_CC, SMTP_BCC (optional, comma-separated lists)

.EXAMPLE
Send-Mail -Subject "Test" -Body "Test content" -FromName "MyApp" -To "recipient@example.com"

Sends a simple email to the recipient.

.EXAMPLE
Send-Mail -Subject "Alert" -Body "<h1>Alert</h1><p>Check the logs.</p>" -IsHtml $true -Priority High

Sends a high priority HTML formatted email with subject "Alert".

.EXAMPLE
Send-Mail -Subject "Report" -Body "Please find the report attached." -To "recipient@example.com" -Attachments @("./report.pdf", "./data.xlsx")

Sends an email with two file attachments.

.EXAMPLE
Send-Mail -Subject "Status" -Body "All good." -To "a@example.com", "b@example.com" -Cc "c@example.com" -Bcc "audit@example.com"

Sends an email to two recipients, with a carbon copy and a blind carbon copy.

.EXAMPLE
Get-Content ./build.log | Send-Mail -Subject "Build failed" -To "team@example.com"

Sends the contents of a log file as the email body.
#>

Function Send-Mail {
    param (
        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [string]$Subject,

        [Parameter(Mandatory=$true, ValueFromPipeline=$true)]
        [AllowEmptyString()]
        [string]$Body,

        [bool]$IsHtml = $False,
        [string]$FromName = $null,
        [string[]]$To = @(),
        [string[]]$Cc = @(),
        [string[]]$Bcc = @(),

        [ValidateSet('Low', 'Normal', 'High')]
        [string]$Priority = 'Normal',

        [string[]]$Attachments = @()
    )

    begin {
        # Piped lines are collected here and sent as a single email in the end block
        $bodyLines = New-Object System.Collections.Generic.List[string]

        # Returns the non-blank addresses given, or the comma-separated list in the environment variable
        function Get-AddressList([string[]]$Addresses, [string]$EnvValue) {
            $list = @($Addresses | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
            if ($list.Count -eq 0 -and -not [string]::IsNullOrWhiteSpace($EnvValue)) { $list = @($EnvValue) }
            return ,$list
        }
    }

    process {
        $bodyLines.Add($Body)
    }

    end {
        $Body = $bodyLines -join [Environment]::NewLine
        if ([string]::IsNullOrWhiteSpace($Body)) {
            Write-Error "The 'Body' parameter is empty."
            return
        }

        # Load environment configuration
        Write-Debug "Call: Subject='$Subject', Body='$Body', IsHtml='$IsHtml', FromName='$FromName', To='$To', Cc='$Cc', Bcc='$Bcc', Priority='$Priority'."
        Write-Debug "Environment: SMTP_SERVER='$env:SMTP_SERVER', SMTP_PORT='$env:SMTP_PORT', SMTP_FROM='$env:SMTP_FROM', SMTP_FROM_NAME='$env:SMTP_FROM_NAME', SMTP_TO='$env:SMTP_TO', SMTP_CC='$env:SMTP_CC', SMTP_BCC='$env:SMTP_BCC', SMTP_USER='$env:SMTP_USER', SMTP_PASS='$(if($env:SMTP_PASS){"[SET]"}else{"[NOT SET]"})'"

        $smtpUser = $env:SMTP_USER
        $smtpPass = $env:SMTP_PASS

        if (-not $smtpUser -or -not $smtpPass) {
            Write-Error "Environment variables SMTP_USER and/or SMTP_PASS are not configured."
            return
        }

        $smtpServer = if ($env:SMTP_SERVER) { $env:SMTP_SERVER } else { "smtp.gmail.com" }
        $smtpPort = if ($env:SMTP_PORT) { $env:SMTP_PORT } else { 587 }
        $smtpFrom = if ($env:SMTP_FROM) { $env:SMTP_FROM } else { $smtpUser }
        if ([string]::IsNullOrWhiteSpace($FromName)) { $FromName = $env:SMTP_FROM_NAME }
        if ([string]::IsNullOrWhiteSpace($FromName)) { $FromName = [Environment]::MachineName }

        $To = Get-AddressList $To $env:SMTP_TO
        $Cc = Get-AddressList $Cc $env:SMTP_CC
        $Bcc = Get-AddressList $Bcc $env:SMTP_BCC
        if ($To.Count -eq 0) {
            Write-Error "The 'To' parameter is required, either in the function call or via the SMTP_TO environment variable."
            return
        }

        Write-Debug "Configuration: SMTP_SERVER='$smtpServer', SMTP_PORT='$smtpPort', SMTP_FROM='$smtpFrom', FROM_NAME='$FromName', SMTP_USER='$smtpUser', TO='$To', CC='$Cc', BCC='$Bcc'."
        Write-Verbose "Sending email to '$($To -join ', ')' with subject '$Subject'..."

        # SMTP client configuration
        $smtpClient = New-Object Net.Mail.SmtpClient($smtpServer, $smtpPort)
        $smtpClient.EnableSsl = $true
        $smtpClient.Credentials = New-Object System.Net.NetworkCredential($smtpUser, $smtpPass)

        # Email message creation (Add accepts a single address or a comma-separated list)
        $mailMessage = New-Object Net.Mail.MailMessage
        $mailMessage.From = "$FromName <$smtpFrom>"
        foreach ($address in $To) { $mailMessage.To.Add($address) }
        foreach ($address in $Cc) { $mailMessage.CC.Add($address) }
        foreach ($address in $Bcc) { $mailMessage.Bcc.Add($address) }
        $mailMessage.Subject = $Subject
        $mailMessage.Body = $Body
        $mailMessage.IsBodyHtml = $IsHtml
        $mailMessage.Priority = [System.Net.Mail.MailPriority]$Priority
        $mailMessage.Headers.Add("X-Mailer", "ElektoMailPosh/$($MyInvocation.MyCommand.Module.Version)")

        # Validate and add attachments
        foreach ($attachmentPath in $Attachments) {
            if (-not (Test-Path -Path $attachmentPath -PathType Leaf)) {
                # Clean up resources before exit (Dispose releases already added attachments)
                $mailMessage.Dispose()
                $smtpClient.Dispose()
                Write-Error "Attachment file not found: '$attachmentPath'"
                return
            }
            $fullPath = (Resolve-Path -Path $attachmentPath).Path
            $attachment = New-Object System.Net.Mail.Attachment($fullPath)
            $mailMessage.Attachments.Add($attachment)
            Write-Verbose "Attachment added: $fullPath"
        }

        # Send email with retry and exponential backoff
        $maxRetries = 5
        $retryCount = 0
        $delay = 1

        try {
            while ($retryCount -lt $maxRetries) {
                try {
                    $smtpClient.Send($mailMessage)
                    Write-Verbose "Email sent successfully."
                    return
                } catch {
                    $retryCount++
                    if ($retryCount -ge $maxRetries) {
                        Write-Error "Failed to send email after $maxRetries attempts: $_"
                        return
                    }
                    Write-Warning "Failed to send email: $_. Retrying in $delay seconds..."
                    Start-Sleep -Seconds $delay
                    $delay *= 2
                }
            }
        } finally {
            # Release resources (MailMessage.Dispose() also releases Attachments)
            $mailMessage.Dispose()
            $smtpClient.Dispose()
        }
    }
}