Send Email from GitLab CI: Deploy Notifications with a Masked Variable
Email your team from GitLab CI: a notify job in the .post stage, a masked variable for the key, safe commit-message escaping, success and failure variants.
GitLab's built-in pipeline emails tell the person who pushed that something failed. They do not tell the rest of the team that production just deployed, and you cannot change what they say. A small job at the end of the pipeline fixes both: it builds a JSON body from the predefined variables and POSTs it to an email API, with the key kept in a masked CI/CD variable. Here is the job, the safe way to include the commit message, and the success and failure variants.
Quick answer
Add OQUMAIL_API_KEY as a masked, protected CI/CD variable. Add a job in the .post stage that installs curl and jq, builds the body with jq from CI_PROJECT_PATH, CI_COMMIT_SHORT_SHA, CI_COMMIT_MESSAGE and CI_PIPELINE_URL, and POSTs it. Use rules with when: on_success and when: on_failure to get two jobs from one template.
The zero-code option, and why it is not enough
Settings, Integrations, Pipeline status emails sends a fixed message to a fixed list of addresses when a pipeline succeeds or fails. If that is all you need, turn it on and stop here. The job below is for when you want the commit message and a link in the subject, different recipients per environment, a message only on the main branch, or a notice that goes to a shared team@ mailbox on your own domain rather than to whoever is on the list.
Add the variable
- In the OquMail dashboard create an API key on the mailbox you will send from, for example deploys@yourdomain.com, and copy it once.
- In GitLab open Settings, CI/CD, Variables and add OQUMAIL_API_KEY with the key as the value.
- Tick Mask variable so it is hidden in job logs, and Protect variable so only pipelines on protected branches and tags can read it.
- Never echo the variable in a script. Masking hides exact matches, not a base64 or split version.
The notify jobs
One hidden template, two jobs. GitLab runs .post after every other stage, and the rules decide which job fires. Predefined variables are already environment variables, so the commit message reaches jq through --arg and is never interpreted by the shell:
.notify:
stage: .post
image: alpine:3.20
before_script:
- apk add --no-cache curl jq
script:
- |
SUBJECT="[deploy ${STATUS}] ${CI_PROJECT_PATH} ${CI_COMMIT_SHORT_SHA} by ${GITLAB_USER_LOGIN}"
BODY=$(jq -n --arg to "team@yourdomain.com" --arg subject "$SUBJECT" \
--arg msg "$CI_COMMIT_MESSAGE" --arg url "$CI_PIPELINE_URL" \
--arg status "$STATUS" --arg sha "$CI_COMMIT_SHORT_SHA" \
'{to:[$to], subject:$subject, fromName:"GitLab CI",
html:("<p>Deploy <strong>"+$status+"</strong> for "+$sha+"</p><pre>"+($msg|@html)+"</pre><p><a href=\""+$url+"\">Pipeline</a></p>"),
text:("Deploy "+$status+" for "+$sha+"\n\n"+$msg+"\n\n"+$url)}')
curl -sS --fail -X POST https://api.oqumail.com/api/v1/emails \
-H "Authorization: Bearer $OQUMAIL_API_KEY" \
-H "Content-Type: application/json" \
-d "$BODY"
notify_success:
extends: .notify
variables:
STATUS: "succeeded"
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: on_success
notify_failure:
extends: .notify
variables:
STATUS: "FAILED"
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: on_failureWhy the body goes through jq
A commit message can contain quotes, backticks and dollar signs. If you paste it into a JSON string by hand inside the script, the first message with a double quote in it breaks the request, and a crafted one on a public project can run commands on your runner with the key in scope. Passing it with --arg makes jq escape it for JSON, and the @html filter escapes it again for the HTML body. The same goes for CI_COMMIT_AUTHOR and anything else that came from a human.
Free business email on your own domain
OquMail gives you up to 15 mailboxes on your domain — free — with guided SPF/DKIM/DMARC, webmail, IMAP/SMTP for any mail app, and a send API. Setup is a few DNS records; changes usually take effect within minutes, occasionally up to 48 hours. Start at oqumail.com.
What the email should say
People read a deploy notice in two seconds, so the subject does the work: status, project, short SHA, who triggered it. The body adds the commit message so the team knows what changed, and a link to the pipeline so a failure is one click from the log. Send it to a shared team@ or deploys@ mailbox rather than to individuals, and let people filter on the [deploy prefix. If you deploy several environments, put CI_ENVIRONMENT_NAME in the subject too.
Useful variations
- Failure only: delete notify_success and keep notify_failure.
- Merge requests: use rules with $CI_PIPELINE_SOURCE == "merge_request_event" and send to the author by mapping GITLAB_USER_LOGIN to an address in a small file in the repository.
- Per-environment recipients: define TO as a variable on the deploy environment under Settings, CI/CD, Variables with the Environment scope set, and pass "$TO" to jq instead of a literal.
- Scheduled pipelines: the same job emails the result of a nightly test run.
- Several projects: move .notify into a shared YAML in a tooling repository and pull it in with include: project.
Sending from your domain
A deploy notice arrives in the team inbox next to customer mail, so it should come from your own domain, DKIM-signed, not from a personal address or a third-party sender. Create deploys@ on the domain in OquMail, generate the key from it, and the message goes out under your domain. The team@ it goes to can be an OquMail mailbox on the same free plan, and every send shows up in the delivery log when someone asks whether the notice went out before an incident.
Common questions
Why alpine and not the curl image?
The curlimages/curl image has no jq. Alpine with two packages installed takes a few seconds and gives you both; pin the Alpine tag so the job does not change under you.
Why does my job not see the variable?
A protected variable is only available on protected branches and tags. If the pipeline runs on a feature branch, the variable is empty and the API returns 401. Either unprotect the variable or restrict the notify rules to protected branches.
What if the send fails?
With --fail curl exits non-zero and the job fails, which marks the pipeline failed. If a notification failure should not do that, add allow_failure: true to the notify jobs. Check the API response in the job log: 401 is a wrong or missing key, 400 is a malformed body.