When your security scans find 47 vulnerabilities at 2 AM, the last thing you want is for the report to disappear into a CI/CD artifact that nobody checks. Email solves this.

The Problem: Security Reports That Nobody Reads

You’ve set up Trivy and Snyk scans in your CI/CD pipeline. Great! Reports are generated. Artifacts are stored. Nobody looks at them until production breaks.

The fix: Email the reports directly to your security team, DevOps team, and whoever else needs to know there are 12 critical CVEs in that Docker image.

The Solution: curl + SMTP with Multipart MIME

No fancy email libraries. No Python scripts. Just curl and an SMTP relay. Works everywhere, requires nothing but curl (which your CI/CD runner already has).

The Script

Store this in a GitLab CI/CD variable called VAPT_EMAIL_REP:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#!/bin/sh
## Email security scan reports with attachments

curl --url 'smtp://smtp-relay.example.com:587' \
--user "$SMTP_USER:$SMTP_PASS" \
--mail-from '[email protected]' \
--mail-rcpt '[email protected]' \
--mail-rcpt '[email protected]' \
--mail-rcpt '[email protected]' \
--mail-rcpt '[email protected]' \
-F '=(;type=text/plain' \
-F "body=Pipeline URL: $CI_PIPELINE_URL" \
-F '=)' \
-F '=(;type=multipart/mixed' \
-F "=Trivy vulnerability scan results attached;" \
-F "file=@trivy_scan_report.txt;type=application/octet-string;encoder=base64" \
-F '=)' \
-F '=(;type=multipart/mixed' \
-F "=Trivy JSON report for automated processing;" \
-F "file=@trivy_scan_report.json;type=application/octet-string;encoder=base64" \
-F '=)' \
-F '=(;type=multipart/mixed' \
-F "=Snyk vulnerability scan results attached;" \
-F "[email protected];type=application/octet-string;encoder=base64" \
-F '=)' \
-F '=(;type=multipart/mixed' \
-F "=Snyk JSON report for automated processing;" \
-F "[email protected];type=application/octet-string;encoder=base64" \
-F '=)' \
-H "Subject: Security Assessment for $CI_PROJECT_NAME commit $CI_COMMIT_SHA" \
-H "From: [email protected]" \
-H "To: [email protected]" \
-H "CC: [email protected]" \
-H "CC: [email protected]" \
-H "CC: [email protected]"

GitLab CI/CD Pipeline Integration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
security_scan_notify:
  stage: notify
  dependencies:
    - trivy_scan
    - snyk_scan
  script:
    # Run the email script from CI/CD variable
    - cat $VAPT_EMAIL_REP > VAPT_EMAIL_REP.sh
    - sh VAPT_EMAIL_REP.sh

    # Optional: Also push reports to GitOps repo
    - cat $VAPT_REPORT_GIT > VAPT_REPORT_GIT.sh
    - sed -i 's/5S5/\$/g' VAPT_REPORT_GIT.sh
    - bash VAPT_REPORT_GIT.sh
  only:
    - main
    - develop

Required GitLab CI/CD Variables

Set these in Settings → CI/CD → Variables:

VariableValueTypeMaskedProtected
SMTP_USERsmtp_usernameVariable
SMTP_PASSsmtp_passwordVariable
VAPT_EMAIL_REP(the script above)File--

Note: Mark credentials as Masked and Protected to prevent exposure in logs.

How It Works

1. The SMTP Connection

1
2
curl --url 'smtp://smtp-relay.example.com:587' \
--user "$SMTP_USER:$SMTP_PASS"
  • Uses SMTP relay (Brevo, SendGrid, AWS SES, etc.)
  • Port 587 for TLS/STARTTLS
  • Credentials from GitLab CI/CD variables

2. Email Recipients

1
2
3
--mail-from '[email protected]' \
--mail-rcpt '[email protected]' \
--mail-rcpt '[email protected]'
  • --mail-from: Envelope sender (must be authorized on SMTP relay)
  • --mail-rcpt: Each recipient gets their own flag
  • Order doesn’t matter

3. Email Body (Plain Text)

1
2
3
-F '=(;type=text/plain' \
-F "body=Pipeline URL: $CI_PIPELINE_URL" \
-F '=)'
  • Creates a text/plain MIME part
  • Uses GitLab’s built-in $CI_PIPELINE_URL variable
  • Opening =( and closing =) delimit the MIME part

4. Attachments (The Magic Part)

1
2
3
4
-F '=(;type=multipart/mixed' \
-F "=Trivy vulnerability scan results attached;" \
-F "file=@trivy_scan_report.txt;type=application/octet-string;encoder=base64" \
-F '=)'

Breakdown:

  • =(;type=multipart/mixed - Start a mixed MIME part (allows attachments)
  • =Trivy vulnerability... - Description text for this attachment
  • file=@trivy_scan_report.txt - The file to attach (@ means read from file)
  • type=application/octet-string - Generic binary type
  • encoder=base64 - Base64 encode for email transmission
  • =) - Close the MIME part

Repeat this block for each attachment.

5. Email Headers

1
2
3
4
-H "Subject: Security Assessment for $CI_PROJECT_NAME commit $CI_COMMIT_SHA" \
-H "From: [email protected]" \
-H "To: [email protected]" \
-H "CC: [email protected]"
  • Subject includes project name and commit SHA
  • From header (display only, envelope sender is --mail-from)
  • To and CC headers (display only, actual recipients are --mail-rcpt)

Why This Pattern Works

✅ No Dependencies

  • Just curl (already in every CI/CD runner)
  • No Python, no Node.js, no sendmail

✅ Works with Any SMTP Relay

  • Brevo (formerly Sendinblue)
  • SendGrid
  • AWS SES
  • Mailgun
  • Your own SMTP server

✅ Multiple Attachments

  • Text reports for humans
  • JSON reports for automation
  • Mix formats as needed

✅ Built-in GitLab Variables

1
2
3
4
$CI_PIPELINE_URL     # Direct link to pipeline
$CI_PROJECT_NAME     # Project name
$CI_COMMIT_SHA       # Commit hash
$CI_COMMIT_REF_NAME  # Branch name

✅ Security Team Gets Notified Immediately

No waiting for someone to check CI/CD artifacts.

Common SMTP Relay Configurations

Brevo (formerly Sendinblue)

1
2
curl --url 'smtp://smtp-relay.brevo.com:587' \
--user "[email protected]:your-brevo-smtp-key"

SendGrid

1
2
curl --url 'smtp://smtp.sendgrid.net:587' \
--user "apikey:SG.your-sendgrid-api-key"

AWS SES

1
2
curl --url 'smtp://email-smtp.us-east-1.amazonaws.com:587' \
--user "your-ses-smtp-username:your-ses-smtp-password"

Gmail (for testing only)

1
2
curl --url 'smtps://smtp.gmail.com:465' \
--user "[email protected]:your-app-password"

Note: Use app-specific passwords, not your regular Gmail password.

Debugging Tips

Test the script locally first

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Set test variables
export SMTP_USER="[email protected]"
export SMTP_PASS="test-password"
export CI_PIPELINE_URL="https://gitlab.com/test/test/-/pipelines/123"
export CI_PROJECT_NAME="test-project"
export CI_COMMIT_SHA="abc123"

# Create dummy report files
echo "Test vulnerability report" > trivy_scan_report.txt
echo '{"vulnerabilities": []}' > trivy_scan_report.json

# Run the script
sh VAPT_EMAIL_REP.sh

Check SMTP connectivity

1
2
# Test SMTP connection (no email sent)
curl -v smtp://smtp-relay.brevo.com:587

Verify authentication

1
2
# Test auth (no email sent)
curl -v --user "$SMTP_USER:$SMTP_PASS" smtp://smtp-relay.brevo.com:587

Send a minimal test email

1
2
3
4
5
6
7
curl --url 'smtp://smtp-relay.brevo.com:587' \
--user "$SMTP_USER:$SMTP_PASS" \
--mail-from '[email protected]' \
--mail-rcpt '[email protected]' \
-F "body=Test email from CI/CD" \
-H "Subject: Test Email" \
-H "From: [email protected]"

Common Issues and Fixes

Issue: “Authentication failed”

Causes:

  • Wrong credentials
  • Using account password instead of SMTP key/app password
  • SMTP relay requires sender verification

Fix:

  • Verify credentials in SMTP relay dashboard
  • Use API key or app password, not account password
  • Verify sender email is authorized

Issue: “Connection refused”

Causes:

  • Wrong port (587 for TLS, 465 for SSL, 25 for plain)
  • Firewall blocking outbound SMTP
  • Wrong SMTP server hostname

Fix:

  • Use port 587 with TLS (most common)
  • Check firewall rules on CI/CD runner
  • Verify SMTP hostname in relay documentation

Issue: “File not found” when attaching

Causes:

  • Report files not generated before email script runs
  • Wrong file path
  • Missing dependencies in GitLab CI/CD job

Fix:

1
2
3
4
5
security_scan_notify:
  stage: notify
  dependencies:
    - trivy_scan    # Ensure this job ran first
    - snyk_scan     # Ensure this job ran first

Issue: Attachments missing or corrupted

Causes:

  • Missing encoder=base64
  • Wrong MIME type
  • File path includes spaces (not quoted)

Fix:

1
2
3
4
5
# Always include encoder=base64 for binary files
-F "[email protected];type=application/octet-string;encoder=base64"

# Quote file paths with spaces
-F "file=@\"scan report.txt\";type=application/octet-string;encoder=base64"

Advanced: Conditional Sending

Only send if vulnerabilities found

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#!/bin/sh
# Check if critical vulnerabilities exist
if grep -q "CRITICAL" trivy_scan_report.txt; then
    echo "Critical vulnerabilities found, sending email..."

    curl --url 'smtp://smtp-relay.brevo.com:587' \
    --user "$SMTP_USER:$SMTP_PASS" \
    --mail-from '[email protected]' \
    --mail-rcpt '[email protected]' \
    -F "body=CRITICAL vulnerabilities detected in $CI_PROJECT_NAME!" \
    -F "file=@trivy_scan_report.txt;type=application/octet-string;encoder=base64" \
    -H "Subject: 🚨 CRITICAL: Security Issues in $CI_PROJECT_NAME" \
    -H "From: [email protected]"
else
    echo "No critical vulnerabilities, skipping email notification"
fi

Different recipients based on severity

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#!/bin/sh
CRITICAL_COUNT=$(grep -c "CRITICAL" trivy_scan_report.txt || echo 0)
HIGH_COUNT=$(grep -c "HIGH" trivy_scan_report.txt || echo 0)

if [ "$CRITICAL_COUNT" -gt 0 ]; then
    # Critical: notify everyone including management
    RECIPIENTS="--mail-rcpt [email protected] \
                --mail-rcpt [email protected] \
                --mail-rcpt [email protected]"
    SUBJECT="🚨 CRITICAL: $CRITICAL_COUNT vulnerabilities in $CI_PROJECT_NAME"
elif [ "$HIGH_COUNT" -gt 0 ]; then
    # High: notify security and devops teams
    RECIPIENTS="--mail-rcpt [email protected] \
                --mail-rcpt [email protected]"
    SUBJECT="⚠️  HIGH: $HIGH_COUNT vulnerabilities in $CI_PROJECT_NAME"
else
    # Low/Medium: just log
    echo "No critical or high vulnerabilities found"
    exit 0
fi

curl --url 'smtp://smtp-relay.brevo.com:587' \
--user "$SMTP_USER:$SMTP_PASS" \
--mail-from '[email protected]' \
$RECIPIENTS \
-F "body=Pipeline: $CI_PIPELINE_URL" \
-F "file=@trivy_scan_report.txt;type=application/octet-string;encoder=base64" \
-H "Subject: $SUBJECT" \
-H "From: [email protected]"

Integration with Other Tools

Combine with Slack notifications

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
security_scan_notify:
  stage: notify
  script:
    # Send email with reports
    - cat $VAPT_EMAIL_REP > VAPT_EMAIL_REP.sh
    - sh VAPT_EMAIL_REP.sh

    # Also send Slack notification
    - |
      curl -X POST $SLACK_WEBHOOK_URL \
      -H 'Content-Type: application/json' \
      -d "{
        \"text\": \"Security scan completed for $CI_PROJECT_NAME\",
        \"attachments\": [{
          \"color\": \"danger\",
          \"text\": \"Check your email for detailed reports\",
          \"fields\": [{
            \"title\": \"Pipeline\",
            \"value\": \"$CI_PIPELINE_URL\",
            \"short\": false
          }]
        }]
      }"

Push reports to Jira

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
security_scan_notify:
  stage: notify
  script:
    # Email reports
    - sh VAPT_EMAIL_REP.sh

    # Create Jira issue if critical vulnerabilities found
    - |
      if grep -q "CRITICAL" trivy_scan_report.txt; then
        curl -X POST $JIRA_API_URL/issue \
        -u "$JIRA_USER:$JIRA_TOKEN" \
        -H 'Content-Type: application/json' \
        -d "{
          \"fields\": {
            \"project\": {\"key\": \"SEC\"},
            \"summary\": \"Critical vulnerabilities in $CI_PROJECT_NAME\",
            \"description\": \"See email for details. Pipeline: $CI_PIPELINE_URL\",
            \"issuetype\": {\"name\": \"Bug\"}
          }
        }"
      fi

Security Considerations

✅ Do’s

  1. Use masked CI/CD variables for SMTP credentials
  2. Use protected variables for production pipelines
  3. Verify sender authorization on SMTP relay
  4. Use TLS/STARTTLS (port 587, not plain port 25)
  5. Limit recipient list to those who need to know

❌ Don’ts

  1. Don’t hardcode credentials in scripts
  2. Don’t use personal email accounts for production
  3. Don’t send to external/public email lists with sensitive scan data
  4. Don’t skip encryption (always use TLS)
  5. Don’t expose SMTP credentials in logs

Real-World Example: Complete Pipeline

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
stages:
  - build
  - scan
  - notify

build:
  stage: build
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

trivy_scan:
  stage: scan
  image: aquasec/trivy:latest
  script:
    - trivy image --format table --output trivy_scan_report.txt $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
    - trivy image --format json --output trivy_scan_report.json $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
  artifacts:
    paths:
      - trivy_scan_report.txt
      - trivy_scan_report.json
    expire_in: 7 days

snyk_scan:
  stage: scan
  image: snyk/snyk:docker
  script:
    - snyk auth $SNYK_TOKEN
    - snyk test --docker $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA > snyk-report.txt || true
    - snyk test --docker $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA --json > snyk-report.json || true
  artifacts:
    paths:
      - snyk-report.txt
      - snyk-report.json
    expire_in: 7 days

security_notify:
  stage: notify
  dependencies:
    - trivy_scan
    - snyk_scan
  script:
    - cat $VAPT_EMAIL_REP > VAPT_EMAIL_REP.sh
    - sh VAPT_EMAIL_REP.sh
  only:
    - main
    - develop

Pro tip: This pattern works for any kind of report email—test results, code coverage, performance benchmarks, deployment summaries. The MIME multipart format supports unlimited attachments, so you can send as many reports as needed in a single email. Just remember that most email providers have attachment size limits (typically 25MB total), so compress large reports or upload them elsewhere and include links instead.


More from me on www.uk4.in.