Send Emails from Azure DevOps Pipelines: Build a Custom SendGrid Extension
Introduction
Ever wanted your Azure DevOps pipeline to send a beautifully formatted email the moment a build finishes, a release deploys, or a test run completes? While Azure DevOps has built-in notifications, they are limited in formatting and flexibility. In this post, we’ll build a custom Azure DevOps pipeline extension that sends emails via SendGrid — a task you can drop into any pipeline, share with your whole organization, and even publish to the Visual Studio Marketplace.
By the end, you’ll have a reusable “Send Email via SendGrid” task with configurable recipients, subject, and HTML body — with the API key kept safely out of your code.
What You'll Need
- SendGrid Account: Sign up at SendGrid and create an API key with Mail Send permission. It will look like
SG.****************— keep it secret; we’ll never hardcode it. - Node.js: The task handler runs on Node. Install the latest LTS version.
- tfx-cli: The cross-platform CLI for packaging Azure DevOps extensions:
npm install -g tfx-cli - Marketplace Publisher Account: Create one at the Visual Studio Marketplace publisher portal — you’ll get a publisher ID (shown below as
{your-publisher-id}).
{your-publisher-id}, {your-organisation}, and SG.**************** are placeholders. Never commit your real SendGrid API key to source control, paste it into a blog, or bake it into the extension package — we’ll pass it in as a secret pipeline variable at runtime instead.
Project Structure
An Azure DevOps extension is just a folder with two manifests and your task code:
SendGridEmailExtension/
├── vss-extension.json ← extension manifest (marketplace listing)
├── icon.png ← 128x128 extension icon
└── SendGridEmailTask/
├── task.json ← task definition (inputs, UI)
├── index.js ← the code that sends the email
└── package.json ← node dependencies
Step 1: Define the Task — task.json
The task.json declares your task’s identity and the inputs users will see in the pipeline UI:
{
"id": "{generate-a-new-guid}",
"name": "SendGridEmail",
"friendlyName": "Send Email via SendGrid",
"description": "Sends an email using the SendGrid API",
"category": "Utility",
"author": "{your-publisher-name}",
"version": { "Major": 1, "Minor": 0, "Patch": 0 },
"instanceNameFormat": "Send email to $(toAddress)",
"inputs": [
{
"name": "apiKey",
"type": "string",
"label": "SendGrid API Key",
"required": true,
"helpMarkDown": "Pass a secret variable, e.g. $(SendGridApiKey)"
},
{
"name": "fromAddress",
"type": "string",
"label": "From Address",
"required": true
},
{
"name": "toAddress",
"type": "string",
"label": "To Address(es)",
"required": true,
"helpMarkDown": "Separate multiple recipients with semicolons"
},
{
"name": "subject",
"type": "string",
"label": "Subject",
"required": true
},
{
"name": "body",
"type": "multiLine",
"label": "Email Body (HTML supported)",
"required": true
}
],
"execution": {
"Node20_1": { "target": "index.js" }
}
}
id must be a fresh GUID unique to your task — generate one with [guid]::NewGuid() in PowerShell. Don’t reuse a GUID from a sample you found online.
Step 2: Write the Task Logic — index.js
The handler reads the inputs, calls the SendGrid API, and reports success or failure back to the pipeline:
const tl = require('azure-pipelines-task-lib/task');
const sgMail = require('@sendgrid/mail');
async function run() {
try {
// Read inputs defined in task.json
const apiKey = tl.getInput('apiKey', true);
const from = tl.getInput('fromAddress', true);
const to = tl.getInput('toAddress', true).split(';');
const subject = tl.getInput('subject', true);
const body = tl.getInput('body', true);
// Never log the API key — mark it as a secret
tl.setSecret(apiKey);
sgMail.setApiKey(apiKey);
await sgMail.send({
to: to,
from: from,
subject: subject,
html: body
});
console.log(`Email sent successfully to ${to.join(', ')}`);
tl.setResult(tl.TaskResult.Succeeded, 'Email sent');
}
catch (err) {
// SendGrid returns detailed errors in err.response.body
const details = err.response?.body
? JSON.stringify(err.response.body)
: err.message;
tl.setResult(tl.TaskResult.Failed, `Send failed: ${details}`);
}
}
run();
Install the two dependencies inside the task folder:
cd SendGridEmailTask
npm install azure-pipelines-task-lib @sendgrid/mail
tl.setSecret(apiKey) tells the pipeline agent to mask the key with *** in all logs — even if some library accidentally prints it. Combined with passing the key as a secret variable, your key never appears anywhere in plain text.
Step 3: The Extension Manifest — vss-extension.json
{
"manifestVersion": 1,
"id": "sendgrid-email-task",
"name": "SendGrid Email Sender",
"version": "1.0.0",
"publisher": "{your-publisher-id}",
"targets": [ { "id": "Microsoft.VisualStudio.Services" } ],
"description": "Send emails from your pipelines using SendGrid.",
"categories": [ "Azure Pipelines" ],
"icons": { "default": "icon.png" },
"files": [ { "path": "SendGridEmailTask" } ],
"contributions": [
{
"id": "sendgrid-email-task",
"type": "ms.vss-distributed-task.task",
"targets": [ "ms.vss-distributed-task.tasks" ],
"properties": { "name": "SendGridEmailTask" }
}
]
}
Step 4: Package and Publish
From the extension root folder, create the .vsix package:
tfx extension create --manifest-globs vss-extension.json
Then upload the generated .vsix in the Visual Studio Marketplace publisher portal. For an internal-only task, keep the extension private and share it with your organisation ({your-organisation}) — it will then appear under Organization Settings → Extensions for installation. Only make it public if you intend the whole world to use it.
Step 5: Use It in a Pipeline
First, add your SendGrid API key as a secret variable: in your pipeline, go to Variables → New variable, name it SendGridApiKey, paste the key, and tick “Keep this value secret”. (For reuse across pipelines, put it in a Variable Group — or better yet, link the group to Azure Key Vault.)
Then call the task from YAML:
steps:
- task: SendGridEmail@1
displayName: 'Notify team of deployment'
inputs:
apiKey: '$(SendGridApiKey)' # secret variable — never the raw key
fromAddress: 'builds@yourdomain.com'
toAddress: 'team@yourdomain.com;qa@yourdomain.com'
subject: '✅ $(Build.DefinitionName) #$(Build.BuildNumber) deployed'
body: '<h2>Deployment succeeded</h2><p>Branch: $(Build.SourceBranchName)</p>'
***.
Security Checklist
- 🔐 API key passed only via a secret variable or Key Vault — never in YAML, code, or the extension package.
- 🚫
tl.setSecret()masks the key in agent logs. - 🔒 Extension kept private and shared only with your organisation unless intended for public use.
- 🔁 Rotate the SendGrid key periodically, and scope it to Mail Send only — a leaked full-access key is a much bigger problem.
Conclusion
With about a hundred lines of JSON and JavaScript, you now have a professional, reusable pipeline task that sends rich HTML emails through SendGrid — installable across every project in your organisation with two clicks. The same extension skeleton (manifest + task.json + Node handler) is the foundation for any custom pipeline task you can imagine: posting to Teams, calling internal APIs, or gating deployments.
Happy coding, and may your pipelines always email you good news!
Comments
Post a Comment