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:
| Variable | Value | Type | Masked | Protected |
|---|
SMTP_USER | smtp_username | Variable | ✓ | ✓ |
SMTP_PASS | smtp_password | Variable | ✓ | ✓ |
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#
--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 attachmentfile=@trivy_scan_report.txt - The file to attach (@ means read from file)type=application/octet-string - Generic binary typeencoder=base64 - Base64 encode for email transmission=) - Close the MIME part
Repeat this block for each attachment.
- 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
|
No waiting for someone to check CI/CD artifacts.
Common SMTP Relay Configurations#
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#
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]"
|
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#
- Use masked CI/CD variables for SMTP credentials
- Use protected variables for production pipelines
- Verify sender authorization on SMTP relay
- Use TLS/STARTTLS (port 587, not plain port 25)
- Limit recipient list to those who need to know
❌ Don’ts#
- Don’t hardcode credentials in scripts
- Don’t use personal email accounts for production
- Don’t send to external/public email lists with sensitive scan data
- Don’t skip encryption (always use TLS)
- 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.