Email API

Send Email from GitHub Actions: Deploy Notifications with a Secret

Send a deploy notification email from GitHub Actions with curl and a repository secret: the YAML step, safe commit message escaping, and failure-only sends.

GitHub notifies the person who pushed when a workflow fails, but nobody else, and it does not tell your team that production just deployed. A ten-line step that POSTs to an email API from the workflow fixes both, using a repository secret for the key. Here is the step, the safe way to include the commit message, and the variations for failure-only and environment-specific mail.

Quick answer

Add your API key as a repository or environment secret named OQUMAIL_API_KEY, then add a step after the deploy that builds a JSON body with jq (already on GitHub-hosted runners) from the workflow context and POSTs it with curl. Use if: always() or if: failure() to control when it runs, and never interpolate untrusted strings like commit messages directly into the shell.

Add the secret

  1. In the OquMail dashboard, create an API key for the mailbox you will send from and copy the oqm_live_ value once; it is shown only at creation.
  2. On GitHub: repository Settings > Secrets and variables > Actions > New repository secret. Name it OQUMAIL_API_KEY.
  3. For production-only sending, put the secret under Settings > Environments > production instead, so only jobs with environment: production can read it.
  4. Never echo the secret in a step; GitHub masks known secrets in logs but a base64 or split version leaks.

The workflow step

Place this after your deploy step. It reads the commit message via an environment variable rather than inlining it, which is the safe pattern for untrusted input:

- name: Email deploy notification
  if: always()
  env:
    OQUMAIL_API_KEY: ${{ secrets.OQUMAIL_API_KEY }}
    STATUS: ${{ job.status }}
    COMMIT_MSG: ${{ github.event.head_commit.message }}
    ACTOR: ${{ github.actor }}
    SHA: ${{ github.sha }}
    RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
  run: |
    SHORT=${SHA:0:7}
    SUBJECT="[deploy ${STATUS}] ${GITHUB_REPOSITORY} ${SHORT} by ${ACTOR}"
    BODY=$(jq -n --arg from "deploys@yourdomain.com" --arg to "team@yourdomain.com" \
      --arg subject "$SUBJECT" --arg msg "$COMMIT_MSG" --arg url "$RUN_URL" --arg status "$STATUS" --arg sha "$SHORT" \
      '{from:$from, to:$to, subject:$subject,
        html:("<p>Deploy <strong>"+$status+"</strong> for "+$sha+"</p><pre>"+($msg|@html)+"</pre><p><a href=\""+$url+"\">Workflow run</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"

Why the commit message goes through env

Anything from github.event is attacker-controlled on public repositories and careless on private ones. If you write ${{ github.event.head_commit.message }} directly inside run:, a commit message containing a quote and a semicolon executes commands on your runner with your secrets in scope. Passing it through env: turns it into an ordinary variable that the shell does not interpret, and jq --arg escapes it for JSON. The @html filter in jq escapes it for the HTML body.

What the email should say

A deploy notification is read in two seconds, so the subject carries the load: the status, the repository, the short SHA and who triggered it. The body adds the commit message (so the team knows what changed), a link to the workflow run (so a failure can be investigated in one click), and, if you have it, the environment and the version deployed. Keep it to that; a deploy email is not the place for a changelog. Send to a shared team@ or deploys@ address rather than individuals, and let people filter it with a mail rule on the [deploy prefix.

Useful variations

  • Failure only: if: failure() on the step, with a subject that starts with [FAILED].
  • Success only on main: if: success() && github.ref == 'refs/heads/main'.
  • Different recipients per environment: set to from an environment variable defined under the environment.
  • Include the deployed version: read it from a file or a previous step output and add it to the subject.
  • Reusable: put the step in a composite action in .github/actions/notify and call it from every workflow.
  • Scheduled workflows: the same step emails the result of a nightly test run to the team.

Sending from your domain

A deploy email arrives in the team's inbox next to customer mail, so it should come from your own domain rather than a personal address or a generic sender. Create deploys@ on the domain in OquMail, generate the API key from it, and the message goes out DKIM-signed under your domain. The team@ address it goes to can be an OquMail mailbox as well, on the free plan, and every send shows up in the delivery log if someone claims they did not get the notice before an incident.

Common questions

Does the runner have curl and jq?

Yes on GitHub-hosted Ubuntu, macOS and Windows runners. On a self-hosted runner install them once. On Windows use bash as the shell for the step (shell: bash).

Can I email pull request authors?

Yes, if you map GitHub usernames to email addresses in a file in the repository; GitHub does not expose a user's email in the context. Send to a team address by default.

What if the send fails?

With --fail curl exits non-zero and the step fails; add continue-on-error: true if a notification failure should not fail the workflow. Check the API response: 401 means the secret is wrong or the key was revoked.

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. Most teams are live in under fifteen minutes. Start at oqumail.com.

Get started free

Ready for business email on your domain?

Up to 15 free mailboxes, guided DNS, webmail, and a transactional API — start in minutes.

Create your free workspace