Guide
Test Email in CI/CD with PowerShell
Verify that your application sends the right emails by testing delivery end-to-end in CI/CD. Send a test email, wait for it to arrive, read it with Nylas CLI, and assert on content -- all from PowerShell steps in GitHub Actions or Azure DevOps pipelines.
The pattern: send, wait, verify
End-to-end email testing follows a simple loop: trigger an email from your application, poll the target inbox until it arrives, then assert on the content. The key insight is that Nylas CLI gives you programmatic inbox access with JSON output -- exactly what you need for CI/CD assertions.
# The core pattern in PowerShell
# 1. Trigger: your app sends an email (password reset, welcome, invoice, etc.)
Invoke-RestMethod -Uri "https://your-app.com/api/reset-password" `
-Method Post `
-Body (@{ email = "test@example.com" } | ConvertTo-Json) `
-ContentType "application/json"
# 2. Wait: poll inbox until the email arrives
$maxRetries = 30
$found = $null
for ($i = 0; $i -lt $maxRetries; $i++) {
$emails = nylas email list --unread --json --limit 10 | ConvertFrom-Json
$found = $emails | Where-Object { $_.subject -match "password reset" }
if ($found) { break }
Start-Sleep -Seconds 10
}
if (-not $found) {
throw "Email did not arrive within timeout"
}
# 3. Verify: assert on content
$detail = nylas email read $found.id --json | ConvertFrom-Json
if ($detail.body -notmatch "https://your-app.com/reset?token=") {
throw "Reset link not found in email body"
}
Write-Host "Email verified successfully" -ForegroundColor GreenGitHub Actions workflow
GitHub Actions supports PowerShell via the pwsh shell on all runner operating systems (Ubuntu, macOS, Windows). Here is a complete workflow that tests password reset email delivery.
# .github/workflows/email-test.yml
name: Email Delivery Tests
on:
push:
branches: [main]
pull_request:
jobs:
test-email:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Nylas CLI
shell: bash
run: |
curl -fsSL https://cli.nylas.com/install.sh | bash
echo "$HOME/.nylas/bin" >> $GITHUB_PATH
- name: Configure Nylas auth
shell: pwsh
env:
NYLAS_AUTH_TOKEN: ${{ secrets.NYLAS_AUTH_TOKEN }}
run: |
# The CLI reads auth from environment or config file
nylas auth whoami
- name: Deploy test app
shell: bash
run: |
# Start your application (adjust for your stack)
docker compose up -d
sleep 10
- name: Trigger password reset email
shell: pwsh
run: |
Invoke-RestMethod `
-Uri "http://localhost:3000/api/reset-password" `
-Method Post `
-Body (@{ email = "test-inbox@example.com" } | ConvertTo-Json) `
-ContentType "application/json"
- name: Wait for email and verify content
shell: pwsh
run: |
$maxRetries = 30
$found = $null
for ($i = 0; $i -lt $maxRetries; $i++) {
Write-Host "Polling attempt $($i + 1)/$maxRetries..."
$emails = nylas email list --unread --json --limit 10 |
ConvertFrom-Json
$found = $emails |
Where-Object { $_.subject -match "password reset" }
if ($found) { break }
Start-Sleep -Seconds 10
}
if (-not $found) {
throw "Password reset email did not arrive within 5 minutes"
}
Write-Host "Found email: $($found.subject)" -ForegroundColor Green
# Read full content
$detail = nylas email read $found.id --json | ConvertFrom-Json
# Verify reset link exists
if ($detail.body -notmatch "https?://[^s]+reset[^s]*token=") {
throw "Reset link not found in email body"
}
Write-Host "Reset link verified" -ForegroundColor GreenAzure DevOps pipeline
Azure DevOps pipelines support PowerShell natively. Use the PowerShell@2 task for inline scripts or reference external .ps1 files.
# azure-pipelines.yml
trigger:
branches:
include:
- main
pool:
vmImage: "windows-latest"
variables:
- group: nylas-secrets # Contains NYLAS_AUTH_TOKEN
steps:
- task: PowerShell@2
displayName: "Install Nylas CLI"
inputs:
targetType: inline
script: |
irm https://cli.nylas.com/install.ps1 | iex
- task: PowerShell@2
displayName: "Verify Nylas auth"
inputs:
targetType: inline
script: nylas auth whoami
env:
NYLAS_AUTH_TOKEN: $(NYLAS_AUTH_TOKEN)
- task: PowerShell@2
displayName: "Send test email"
inputs:
targetType: inline
script: |
nylas email send `
--to "test-inbox@example.com" `
--subject "CI Build $(Build.BuildNumber)" `
--body "This is an automated test email from build $(Build.BuildNumber)." `
--yes --json
env:
NYLAS_AUTH_TOKEN: $(NYLAS_AUTH_TOKEN)
- task: PowerShell@2
displayName: "Verify email delivery"
inputs:
targetType: inline
script: |
$buildNumber = "$(Build.BuildNumber)"
$maxRetries = 30
$found = $null
for ($i = 0; $i -lt $maxRetries; $i++) {
Write-Host "Polling attempt $($i + 1)/$maxRetries..."
$emails = nylas email list --unread --json --limit 20 |
ConvertFrom-Json
$found = $emails |
Where-Object { $_.subject -match $buildNumber }
if ($found) { break }
Start-Sleep -Seconds 10
}
if (-not $found) {
Write-Error "Test email did not arrive within timeout"
exit 1
}
Write-Host "##vso[task.complete result=Succeeded;]Email delivered"
env:
NYLAS_AUTH_TOKEN: $(NYLAS_AUTH_TOKEN)Pester test assertions
Pester is PowerShell's testing framework -- the equivalent of Jest or pytest. Use it to write structured assertions on email content with proper test reporting.
# tests/Email.Tests.ps1
BeforeAll {
# Helper: poll until email arrives or timeout
function Wait-ForEmail {
param(
[string]$SubjectPattern,
[int]$TimeoutSeconds = 300,
[int]$IntervalSeconds = 10
)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
$emails = nylas email list --unread --json --limit 20 |
ConvertFrom-Json
$match = $emails |
Where-Object { $_.subject -match $SubjectPattern }
if ($match) { return $match }
Start-Sleep -Seconds $IntervalSeconds
}
return $null
}
}
Describe "Password Reset Email" {
BeforeAll {
# Trigger password reset
Invoke-RestMethod `
-Uri "http://localhost:3000/api/reset-password" `
-Method Post `
-Body (@{ email = "test@example.com" } | ConvertTo-Json) `
-ContentType "application/json"
# Wait for it
$script:email = Wait-ForEmail -SubjectPattern "password reset"
if ($script:email) {
$script:detail = nylas email read $script:email.id --json |
ConvertFrom-Json
}
}
It "should arrive within 5 minutes" {
$script:email | Should -Not -BeNullOrEmpty
}
It "should have the correct subject" {
$script:email.subject | Should -Match "Reset your password"
}
It "should be from the no-reply address" {
$script:email.from | Should -Match "no-reply@"
}
It "should contain a reset link" {
$script:detail.body | Should -Match "https?://[^s]+/reset?token="
}
It "should not contain sensitive data" {
$script:detail.body | Should -Not -Match "password"
$script:detail.body | Should -Not -Match "secret"
}
It "should have an unsubscribe header" {
$script:detail.headers."List-Unsubscribe" |
Should -Not -BeNullOrEmpty
}
}
Describe "Welcome Email" {
BeforeAll {
Invoke-RestMethod `
-Uri "http://localhost:3000/api/register" `
-Method Post `
-Body (@{
email = "test@example.com"
name = "Test User"
} | ConvertTo-Json) `
-ContentType "application/json"
$script:email = Wait-ForEmail -SubjectPattern "welcome"
if ($script:email) {
$script:detail = nylas email read $script:email.id --json |
ConvertFrom-Json
}
}
It "should arrive within 5 minutes" {
$script:email | Should -Not -BeNullOrEmpty
}
It "should greet the user by name" {
$script:detail.body | Should -Match "Test User"
}
It "should include a getting started link" {
$script:detail.body | Should -Match "getting.started"
}
}Run the tests locally or in CI:
# Run Pester tests
Invoke-Pester -Path ./tests/Email.Tests.ps1 -Output Detailed
# Generate JUnit XML for CI reporting
Invoke-Pester -Path ./tests/Email.Tests.ps1 `
-OutputFile ./test-results.xml `
-OutputFormat JUnitXmlTest OTP verification flows
The most common CI/CD email test: trigger a password reset or signup, extract the OTP code, and use it to complete the flow. The nylas otp get command extracts verification codes automatically.
# test-otp-flow.ps1 -- Full password reset E2E test
# 1. Trigger password reset
Invoke-RestMethod `
-Uri "http://localhost:3000/api/reset-password" `
-Method Post `
-Body (@{ email = "test@example.com" } | ConvertTo-Json) `
-ContentType "application/json"
# 2. Wait for OTP code
Start-Sleep -Seconds 5
$code = $null
for ($i = 0; $i -lt 30; $i++) {
$code = nylas otp get --raw
if ($code) { break }
Start-Sleep -Seconds 10
}
if (-not $code) {
throw "OTP code did not arrive"
}
Write-Host "Got OTP code: $code" -ForegroundColor Green
# 3. Submit the OTP code to complete the reset
$response = Invoke-RestMethod `
-Uri "http://localhost:3000/api/verify-reset" `
-Method Post `
-Body (@{
email = "test@example.com"
code = $code
newPassword = "NewSecureP@ss123"
} | ConvertTo-Json) `
-ContentType "application/json"
if ($response.success -ne $true) {
throw "OTP verification failed: $($response.error)"
}
Write-Host "Password reset completed successfully" -ForegroundColor GreenExtract and verify reset links
When your app sends a reset link instead of an OTP code, extract the URL from the email body and verify it works.
# test-reset-link.ps1 -- Extract reset link and verify it loads
# 1. Trigger password reset
Invoke-RestMethod `
-Uri "http://localhost:3000/api/reset-password" `
-Method Post `
-Body (@{ email = "test@example.com" } | ConvertTo-Json) `
-ContentType "application/json"
# 2. Wait for and read the email
$found = $null
for ($i = 0; $i -lt 30; $i++) {
$emails = nylas email list --unread --json --limit 10 | ConvertFrom-Json
$found = $emails | Where-Object { $_.subject -match "reset" }
if ($found) { break }
Start-Sleep -Seconds 10
}
if (-not $found) { throw "Reset email did not arrive" }
$detail = nylas email read $found.id --json | ConvertFrom-Json
# 3. Extract the reset link
if ($detail.body -match '(https?://[^s"<]+reset[^s"<]*token=[^s"<]+)') {
$resetLink = $matches[1]
Write-Host "Found reset link: $resetLink" -ForegroundColor Green
} else {
throw "No reset link found in email body"
}
# 4. Verify the link returns 200
$response = Invoke-WebRequest -Uri $resetLink -UseBasicParsing
if ($response.StatusCode -ne 200) {
throw "Reset link returned status $($response.StatusCode)"
}
Write-Host "Reset link is valid (HTTP 200)" -ForegroundColor GreenCheck for bounces in CI
After sending test emails, verify none bounced. This catches misconfigured SMTP, SPF failures, or invalid recipient addresses.
# check-bounces.ps1 -- Verify no emails bounced
$emails = nylas email list --json --limit 50 | ConvertFrom-Json
$bounces = $emails | Where-Object {
$_.from -match "mailer-daemon|postmaster" -or
$_.subject -match "delivery.*fail|undeliverable|bounced"
}
if ($bounces) {
Write-Host "BOUNCES DETECTED:" -ForegroundColor Red
foreach ($bounce in $bounces) {
Write-Host " Subject: $($bounce.subject)" -ForegroundColor Red
Write-Host " From: $($bounce.from)" -ForegroundColor DarkRed
}
throw "Found $($bounces.Count) bounced email(s)"
}
Write-Host "No bounces detected" -ForegroundColor GreenGitHub Actions with Pester reporting
Combine everything into a workflow that runs Pester tests and publishes results as a GitHub check.
# .github/workflows/email-pester.yml
name: Email Tests (Pester)
on:
push:
branches: [main]
jobs:
email-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Nylas CLI
shell: bash
run: |
curl -fsSL https://cli.nylas.com/install.sh | bash
echo "$HOME/.nylas/bin" >> $GITHUB_PATH
- name: Start test app
shell: bash
run: docker compose up -d && sleep 10
- name: Run Pester email tests
shell: pwsh
env:
NYLAS_AUTH_TOKEN: ${{ secrets.NYLAS_AUTH_TOKEN }}
run: |
Install-Module -Name Pester -Force -Scope CurrentUser
$config = New-PesterConfiguration
$config.TestResult.Enabled = $true
$config.TestResult.OutputPath = "test-results.xml"
$config.TestResult.OutputFormat = "JUnitXml"
$config.Output.Verbosity = "Detailed"
Invoke-Pester -Configuration $config
- name: Publish test results
uses: dorny/test-reporter@v1
if: always()
with:
name: Email Tests
path: test-results.xml
reporter: java-junitFrequently asked questions
Can I test email delivery in CI/CD without a real mailbox?
You need a real mailbox to verify end-to-end delivery. Nylas CLI authenticates with Gmail, Outlook, or any IMAP provider. Use a dedicated test account. The CLI handles OAuth tokens -- store the refresh token as a CI/CD secret and the CLI will re-authenticate automatically.
How do I wait for an email in a CI/CD pipeline?
Use a PowerShell polling loop: call nylas email list --json repeatedly with Start-Sleep between attempts. Set a maximum retry count (e.g., 30 retries at 10 seconds = 5 minutes). If the email does not arrive within the timeout, fail the test. For OTP codes specifically, use nylas otp watch which blocks until a code arrives.
Does Nylas CLI work in GitHub Actions runners?
Yes. Nylas CLI is a standalone Go binary available for Linux, macOS, and Windows. In GitHub Actions, install it in a setup step, then call it from pwsh steps. Store your Nylas auth token as a repository secret.
Can I use Pester to assert on email content?
Yes. Call Nylas CLI to fetch the email, parse the JSON with ConvertFrom-Json, then use Pester's Should assertions to verify subject, sender, body content, and attachments. Pester integrates with both GitHub Actions and Azure DevOps test reporting.
Next steps
- Monitor your inbox with PowerShell -- inbox polling, alerts, and OTP watching
- E2E email testing with Playwright -- browser-based email verification
- Email deliverability from the CLI -- debug SPF, DKIM, and DMARC
- Full command reference -- every flag and subcommand