Build a Secure ChatGPT → GitHub → Mailcow Email Bridge
This guide documents how to build a small, private mail bridge that allows ChatGPT-assisted emails to be sent through your own Mailcow server while keeping the real sender address under your control.
I built this because the Gmail connection I was using for ChatGPT mailbox access would send outgoing mail using the authenticated Gmail account rather than my Switch Vision address.
The finished system allows a workflow like:
Code: Select all
ChatGPT
↓
Private GitHub mail queue
↓
GitHub self-hosted runner
↓
Dedicated Debian LXC
↓
Protected local mail daemon
↓
Mailcow SMTP
↓
Recipient
Code: Select all
Mailer
↓
Mailcow IMAP
↓
Sent
↓
SOGo
GitHub is only used as a private job queue.
[notice]
No inbound ports need to be exposed to the Internet.
The GitHub self-hosted runner initiates its own outbound connection to GitHub.
[/notice]
[warning]
The body of each outgoing email is temporarily stored inside a PRIVATE GitHub issue.
Do not make the queue repository public.
The Mailcow password is NEVER stored in GitHub, a GitHub secret, an issue, or ChatGPT.
[/warning]
What the finished bridge provides
• Fixed sender identity
• SMTP submission through Mailcow
• IMAP copy into the Mailcow Sent folder
• Private GitHub queue
• Dedicated self-hosted runner
• No publicly exposed listener
• Root-only Mailcow credentials
• Duplicate-send protection
• Header validation
• Maximum message-size protection
• Automatic disclosure footer
• GitHub issue automatically closes after successful delivery
• SMTP failure does not blindly retry and risk duplicate delivery
Example configuration
For this guide I use:
Code: Select all
LXC hostname:
switch-vision-mailer
Linux user:
switchvision
Mailer service user:
svmailer
GitHub repository:
<GITHUB_USER>/switch-vision-mail-queue
Mail account:
switch-vision@example.com
Mail server:
mail.example.com
SMTP:
587 / STARTTLS
IMAP:
993 / SSL
1. Create the Debian LXC
I used a small unprivileged Debian 13 LXC under Proxmox.
Suggested configuration:
Code: Select all
Hostname: switch-vision-mailer
CPU: 1 core
RAM: 512 MB - 1 GB
Swap: 512 MB
Disk: 8 GB
Type: Unprivileged
No nesting is required.
No privileged container is required.
The mailer needs network access to:
Code: Select all
GitHub TCP 443
Mailcow SMTP TCP 587
Mailcow IMAP TCP 993
2. Update Debian and install packages
Log into the new container as root.
Code: Select all
apt update
apt full-upgrade -y
Code: Select all
apt install -y \
ca-certificates \
curl \
git \
python3 \
python3-venv \
python3-pip \
jq \
openssl \
unzip \
sqlite3 \
lsof \
socat \
netcat-openbsd
Code: Select all
timedatectl set-timezone Australia/Sydney
Code: Select all
timedatectl
3. Create the GitHub runner user
Create a dedicated account:
Code: Select all
useradd \
--create-home \
--shell /bin/bash \
switchvision
Code: Select all
install -d -o switchvision -g switchvision -m 750 \
/opt/switch-vision-mailer \
/var/lib/switch-vision-mailer
Code: Select all
install -d -o root -g root -m 700 \
/etc/switch-vision-mailer
4. Verify network access
Test GitHub:
Code: Select all
getent hosts github.com
curl -I https://github.com
Code: Select all
openssl s_client \
-connect mail.example.com:587 \
-starttls smtp \
-servername mail.example.com </dev/null
Code: Select all
openssl s_client \
-connect mail.example.com:993 \
-servername mail.example.com </dev/null
Do not enter mailbox passwords into these commands.
5. Create the private GitHub repository
Create:
Code: Select all
<GITHUB_USER>/switch-vision-mail-queue
Code: Select all
PRIVATE
[warning]
This repository contains outgoing email jobs.
DO NOT make it public.
[/warning]
6. Register the self-hosted GitHub runner
Open:
Code: Select all
Repository
→ Settings
→ Actions
→ Runners
→ New self-hosted runner
Code: Select all
Linux
x64
Do not copy old runner URLs from another guide because the version and registration token change.
On the LXC:
Code: Select all
su - switchvision
mkdir -p ~/actions-runner
cd ~/actions-runner
Then paste GitHub's generated configure command.
When prompted I used:
Code: Select all
Runner group:
Default
Runner name:
switch-vision-mailer
Additional labels:
switch-vision-mailer
Work folder:
_work
Code: Select all
exit
Code: Select all
cd /home/switchvision/actions-runner
./svc.sh install switchvision
./svc.sh start
./svc.sh status
Code: Select all
switch-vision-mailer
Idle
7. Give ChatGPT access to the PRIVATE queue repository
If ChatGPT is going to create the queue issues, its GitHub connection must also have permission to see the private repository.
Add:
Code: Select all
<GITHUB_USER>/switch-vision-mail-queue
The GitHub runner registration and ChatGPT GitHub repository access are separate permissions.
The runner can be online and Idle even if ChatGPT cannot see the repository.
8. Create a dedicated Mailcow app password
Create a new app password for the mailbox that will send the messages.
For example:
Code: Select all
Switch Vision Mailer
Code: Select all
IMAP access: enabled
SMTP access: enabled
This caused an authentication problem during my initial setup.
IMAP worked but SMTP returned:
535 5.7.8 authentication failed
The cause was the app-password SMTP permission.
Make sure BOTH IMAP and SMTP are enabled.
[/warning]
Do not reuse your normal Mailcow password if you can avoid it.
A dedicated app password can be revoked independently later.
9. Store the Mailcow password locally
The password stays on the LXC.
It is NOT stored in GitHub.
Run as root:
Code: Select all
install -d -o root -g root -m 700 \
/etc/switch-vision-mailer
Code: Select all
read -rsp "Paste Mailcow app password: " SVPASS
echo
Code: Select all
umask 077
printf '%s' "$SVPASS" \
> /etc/switch-vision-mailer/mailcow.pass
unset SVPASS
Code: Select all
chown root:root \
/etc/switch-vision-mailer/mailcow.pass
chmod 600 \
/etc/switch-vision-mailer/mailcow.pass
Code: Select all
ls -l /etc/switch-vision-mailer/mailcow.pass
10. Test SMTP authentication
Change the hostname and username below.
Code: Select all
python3 - <<'PY'
import ssl
import smtplib
from pathlib import Path
host = "mail.example.com"
user = "switch-vision@example.com"
password = Path(
"/etc/switch-vision-mailer/mailcow.pass"
).read_text().strip()
print("Testing SMTP 587 STARTTLS...")
ctx = ssl.create_default_context()
smtp = smtplib.SMTP(
host,
587,
timeout=15
)
smtp.ehlo()
smtp.starttls(context=ctx)
smtp.ehlo()
smtp.login(
user,
password
)
smtp.noop()
smtp.quit()
print("SMTP AUTH: OK")
PY
Code: Select all
Testing SMTP 587 STARTTLS...
SMTP AUTH: OK
11. Test IMAP authentication
Code: Select all
python3 - <<'PY'
import ssl
import imaplib
from pathlib import Path
host = "mail.example.com"
user = "switch-vision@example.com"
password = Path(
"/etc/switch-vision-mailer/mailcow.pass"
).read_text().strip()
imap = imaplib.IMAP4_SSL(
host,
993,
ssl_context=ssl.create_default_context()
)
imap.login(
user,
password
)
print("IMAP AUTH: OK")
status, folders = imap.list()
print("Folders:")
for folder in folders or []:
print(folder.decode(errors="replace"))
imap.logout()
PY
Code: Select all
IMAP AUTH: OK
12. Create the mailer configuration
Create:
Code: Select all
nano /etc/switch-vision-mailer/config.env
Code: Select all
MAIL_HOST=mail.example.com
MAIL_USER=switch-vision@example.com
MAIL_FROM_NAME="Switch Vision"
SMTP_PORT=587
IMAP_PORT=993
ALLOWED_REPO=<GITHUB_USER>/switch-vision-mail-queue
APPROVED_BY=<GITHUB_USER>
RUNNER_USER=switchvision
Code: Select all
chown root:root \
/etc/switch-vision-mailer/config.env
chmod 600 \
/etc/switch-vision-mailer/config.env
13. Create the private GitHub workflow
Create:
Code: Select all
.github/workflows/send-mail.yml
Code: Select all
name: Switch Vision Mail Queue
on:
issues:
types: [opened]
permissions:
contents: read
issues: write
jobs:
send-mail:
if: startsWith(
github.event.issue.title,
'[SVMAIL] '
)
runs-on:
- self-hosted
- Linux
- X64
- switch-vision-mailer
concurrency:
group: svmail-${{ github.event.issue.number }}
cancel-in-progress: false
steps:
- name: Process mail job
env:
SVMAIL_REPOSITORY: ${{ github.repository }}
SVMAIL_ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
set -euo pipefail
/opt/switch-vision-mailer/bin/process-github-event \
"$GITHUB_EVENT_PATH"
- name: Close completed mail job
if: success()
env:
GH_TOKEN: ${{ github.token }}
ISSUE_API_URL: ${{ github.event.issue.url }}
COMMENTS_API_URL: ${{ github.event.issue.comments_url }}
run: |
set -euo pipefail
curl -fsS \
-H "Authorization: Bearer $GH_TOKEN" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
-X POST "$COMMENTS_API_URL" \
-d '{"body":"Mail job completed successfully."}' \
>/dev/null
curl -fsS \
-H "Authorization: Bearer $GH_TOKEN" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
-X PATCH "$ISSUE_API_URL" \
-d '{"state":"closed"}' \
>/dev/null
Code: Select all
[SVMAIL]
14. Create the local mailer daemon
Create:
Code: Select all
mkdir -p /opt/switch-vision-mailer/bin
nano /opt/switch-vision-mailer/bin/mailer-daemon
Code: Select all
#!/usr/bin/env python3
import hashlib
import imaplib
import json
import os
import pwd
import re
import smtplib
import socket
import sqlite3
import ssl
import struct
import sys
import time
from email.message import EmailMessage
from email.policy import SMTP
from email.utils import (
formataddr,
formatdate,
make_msgid,
parseaddr,
)
from pathlib import Path
SOCKET_PATH = (
"/run/switch-vision-mailer/mailer.sock"
)
DB_PATH = (
"/var/lib/switch-vision-mailer/state.db"
)
MAX_REQUEST = 256 * 1024
FOOTER = (
"This email was drafted with the assistance "
"of ChatGPT and reviewed by zemerdon before sending."
)
MAIL_HOST = os.environ.get(
"MAIL_HOST",
"mail.example.com"
)
MAIL_USER = os.environ.get(
"MAIL_USER",
"switch-vision@example.com"
)
MAIL_FROM_NAME = os.environ.get(
"MAIL_FROM_NAME",
"Switch Vision"
)
SMTP_PORT = int(
os.environ.get(
"SMTP_PORT",
"587"
)
)
IMAP_PORT = int(
os.environ.get(
"IMAP_PORT",
"993"
)
)
ALLOWED_REPO = os.environ.get(
"ALLOWED_REPO",
"<GITHUB_USER>/switch-vision-mail-queue",
)
APPROVED_BY = os.environ.get(
"APPROVED_BY",
"<GITHUB_USER>"
)
RUNNER_USER = os.environ.get(
"RUNNER_USER",
"switchvision"
)
CRED_DIR = os.environ.get(
"CREDENTIALS_DIRECTORY"
)
if not CRED_DIR:
raise SystemExit(
"CREDENTIALS_DIRECTORY is not set"
)
PASSWORD = Path(
CRED_DIR,
"mailcow.pass",
).read_text().strip()
if not PASSWORD:
raise SystemExit(
"Mailcow credential is empty"
)
RUNNER_UID = pwd.getpwnam(
RUNNER_USER
).pw_uid
DOMAIN = MAIL_USER.split(
"@",
1
)[1]
def db_connect():
db = sqlite3.connect(
DB_PATH
)
db.execute("""
CREATE TABLE IF NOT EXISTS jobs (
job_id TEXT PRIMARY KEY,
payload_hash TEXT NOT NULL,
state TEXT NOT NULL,
recipient TEXT NOT NULL,
subject TEXT NOT NULL,
message_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_error TEXT
)
""")
db.commit()
return db
def fail(message):
raise ValueError(message)
def validate_header(
value,
name,
max_len,
):
if not isinstance(
value,
str
):
fail(
f"{name} must be a string"
)
value = value.strip()
if (
not value
or len(value) > max_len
or "\r" in value
or "\n" in value
):
fail(
f"invalid {name}"
)
return value
def validate_job(job):
if not isinstance(
job,
dict
):
fail(
"request must be an object"
)
if job.get("type") == "health":
return {
"type": "health"
}
if job.get("version") != 1:
fail(
"unsupported job version"
)
if job.get("repo") != ALLOWED_REPO:
fail(
"repository not allowed"
)
if job.get(
"approved_by"
) != APPROVED_BY:
fail(
"job is not approved"
)
issue_number = job.get(
"issue_number"
)
if (
not isinstance(
issue_number,
int
)
or issue_number < 1
):
fail(
"invalid issue number"
)
expected_job_id = (
f"{ALLOWED_REPO}"
f"#{issue_number}"
)
if job.get(
"job_id"
) != expected_job_id:
fail(
"invalid job id"
)
recipient = validate_header(
job.get("to"),
"recipient",
320,
)
display, addr = parseaddr(
recipient
)
if (
display
or addr != recipient
):
fail(
"recipient must be "
"a single bare email address"
)
if not re.fullmatch(
r"[^@\s]+@[^@\s]+\.[^@\s]+",
addr,
):
fail(
"invalid recipient address"
)
subject = validate_header(
job.get("subject"),
"subject",
240,
)
body = job.get("body")
if (
not isinstance(
body,
str
)
or not body.strip()
):
fail(
"body must be non-empty"
)
if len(
body.encode("utf-8")
) > 100_000:
fail(
"body too large"
)
reply_mid = job.get(
"reply_message_id"
)
if reply_mid in (
None,
"",
):
reply_mid = None
else:
reply_mid = validate_header(
reply_mid,
"reply_message_id",
998,
)
if not re.fullmatch(
r"<[^<>\s]+>",
reply_mid,
):
fail(
"invalid reply Message-ID"
)
return {
"type": "send",
"version": 1,
"repo": ALLOWED_REPO,
"issue_number":
issue_number,
"job_id":
expected_job_id,
"approved_by":
APPROVED_BY,
"to":
addr,
"subject":
subject,
"body":
body.rstrip(),
"reply_message_id":
reply_mid,
}
def payload_hash(job):
payload = {
key: job[key]
for key in (
"version",
"repo",
"issue_number",
"job_id",
"approved_by",
"to",
"subject",
"body",
"reply_message_id",
)
}
raw = json.dumps(
payload,
sort_keys=True,
ensure_ascii=False,
separators=(
",",
":"
),
).encode()
return hashlib.sha256(
raw
).hexdigest()
def build_message(
job,
message_id,
created_at,
):
body = job[
"body"
].rstrip()
if FOOTER not in body:
body += (
"\n\n"
+ FOOTER
)
msg = EmailMessage(
policy=SMTP
)
msg["From"] = formataddr(
(
MAIL_FROM_NAME,
MAIL_USER,
)
)
msg["To"] = job["to"]
msg["Reply-To"] = (
MAIL_USER
)
msg["Subject"] = (
job["subject"]
)
msg["Date"] = formatdate(
created_at,
localtime=False,
usegmt=True,
)
msg["Message-ID"] = (
message_id
)
if job[
"reply_message_id"
]:
msg[
"In-Reply-To"
] = job[
"reply_message_id"
]
msg[
"References"
] = job[
"reply_message_id"
]
msg.set_content(
body
)
return msg
def smtp_send(msg):
ctx = (
ssl.create_default_context()
)
with smtplib.SMTP(
MAIL_HOST,
SMTP_PORT,
timeout=30,
) as smtp:
smtp.ehlo()
smtp.starttls(
context=ctx
)
smtp.ehlo()
smtp.login(
MAIL_USER,
PASSWORD,
)
smtp.send_message(
msg
)
def find_sent_folder(imap):
status, folders = (
imap.list()
)
if status == "OK":
for raw in folders or []:
line = raw.decode(
errors="replace"
)
if r"\Sent" in line:
match = re.search(
r'(?:"([^"]*)"|([^\s]+))\s*$',
line,
)
if match:
return (
match.group(1)
or match.group(2)
)
for candidate in (
"Sent",
"Sent Items",
"Sent Messages",
):
status, matches = imap.list(
"",
candidate,
)
if (
status == "OK"
and matches
and matches[0]
):
return candidate
raise RuntimeError(
"could not locate IMAP Sent folder"
)
def append_sent(
msg,
created_at,
):
ctx = (
ssl.create_default_context()
)
with imaplib.IMAP4_SSL(
MAIL_HOST,
IMAP_PORT,
ssl_context=ctx,
) as imap:
imap.login(
MAIL_USER,
PASSWORD,
)
folder = (
find_sent_folder(
imap
)
)
raw = msg.as_bytes(
policy=SMTP
)
status, data = imap.append(
folder,
r"\Seen",
imaplib.Time2Internaldate(
created_at
),
raw,
)
if status != "OK":
raise RuntimeError(
f"IMAP APPEND failed: "
f"{data!r}"
)
def update_state(
db,
job_id,
state,
error=None,
):
db.execute(
"""
UPDATE jobs
SET state=?,
updated_at=?,
last_error=?
WHERE job_id=?
""",
(
state,
int(time.time()),
error,
job_id,
),
)
db.commit()
def process_send(job):
phash = payload_hash(
job
)
db = db_connect()
try:
row = db.execute(
"""
SELECT
payload_hash,
state,
message_id,
created_at
FROM jobs
WHERE job_id=?
""",
(
job["job_id"],
),
).fetchone()
if row:
(
old_hash,
state,
message_id,
created_at,
) = row
if old_hash != phash:
fail(
"job id already exists "
"with different content"
)
if state == "sent":
return {
"ok": True,
"status":
"already-sent",
"message_id":
message_id,
}
if state == "smtp_sent":
msg = build_message(
job,
message_id,
created_at,
)
append_sent(
msg,
created_at,
)
update_state(
db,
job["job_id"],
"sent",
)
return {
"ok": True,
"status":
"sent-copy-repaired",
"message_id":
message_id,
}
fail(
"job is locked in state "
f"{state}; refusing "
"automatic resend"
)
created_at = int(
time.time()
)
message_id = make_msgid(
domain=DOMAIN
)
db.execute(
"""
INSERT INTO jobs (
job_id,
payload_hash,
state,
recipient,
subject,
message_id,
created_at,
updated_at,
last_error
)
VALUES (
?,?,?,?,?,?,?,?,NULL
)
""",
(
job["job_id"],
phash,
"sending",
job["to"],
job["subject"],
message_id,
created_at,
created_at,
),
)
db.commit()
msg = build_message(
job,
message_id,
created_at,
)
try:
smtp_send(
msg
)
except Exception as exc:
update_state(
db,
job["job_id"],
"send_uncertain",
type(exc).__name__,
)
raise
update_state(
db,
job["job_id"],
"smtp_sent",
)
try:
append_sent(
msg,
created_at,
)
except Exception as exc:
update_state(
db,
job["job_id"],
"smtp_sent",
type(exc).__name__,
)
raise
update_state(
db,
job["job_id"],
"sent",
)
print(
f"job={job['job_id']} "
f"state=sent "
f"recipient={job['to']}",
flush=True,
)
return {
"ok": True,
"status": "sent",
"message_id":
message_id,
}
finally:
db.close()
def send_response(
conn,
obj,
):
conn.sendall(
(
json.dumps(
obj,
separators=(
",",
":"
),
)
+ "\n"
).encode()
)
def handle_connection(conn):
creds = conn.getsockopt(
socket.SOL_SOCKET,
socket.SO_PEERCRED,
struct.calcsize(
"3i"
),
)
(
_pid,
uid,
_gid,
) = struct.unpack(
"3i",
creds,
)
if uid != RUNNER_UID:
send_response(
conn,
{
"ok": False,
"error":
"unauthorized local uid",
},
)
return
data = bytearray()
while b"\n" not in data:
chunk = conn.recv(
65536
)
if not chunk:
break
data.extend(
chunk
)
if len(
data
) > MAX_REQUEST:
send_response(
conn,
{
"ok": False,
"error":
"request too large",
},
)
return
try:
request = json.loads(
bytes(data)
.split(
b"\n",
1
)[0]
.decode(
"utf-8"
)
)
job = validate_job(
request
)
if job[
"type"
] == "health":
send_response(
conn,
{
"ok": True,
"status":
"healthy",
},
)
return
result = process_send(
job
)
send_response(
conn,
result,
)
except Exception as exc:
print(
"request failed: "
f"{type(exc).__name__}: "
f"{exc}",
file=sys.stderr,
flush=True,
)
send_response(
conn,
{
"ok": False,
"error":
f"{type(exc).__name__}: "
f"{exc}",
},
)
def main():
Path(
SOCKET_PATH
).parent.mkdir(
parents=True,
exist_ok=True,
)
try:
os.unlink(
SOCKET_PATH
)
except FileNotFoundError:
pass
db = db_connect()
db.close()
server = socket.socket(
socket.AF_UNIX,
socket.SOCK_STREAM,
)
server.bind(
SOCKET_PATH
)
os.chmod(
SOCKET_PATH,
0o660,
)
server.listen(
16
)
print(
"Switch Vision mailer "
"listening on "
f"{SOCKET_PATH}",
flush=True,
)
while True:
conn, _ = (
server.accept()
)
with conn:
handle_connection(
conn
)
if __name__ == "__main__":
main()
15. Create the GitHub-event processor
Create:
Code: Select all
nano /opt/switch-vision-mailer/bin/process-github-event
Code: Select all
#!/usr/bin/env python3
import json
import socket
import sys
SOCKET_PATH = (
"/run/switch-vision-mailer/mailer.sock"
)
ALLOWED_REPO = (
"<GITHUB_USER>/switch-vision-mail-queue"
)
def die(message):
print(
f"SVMAIL ERROR: {message}",
file=sys.stderr,
)
raise SystemExit(1)
if len(sys.argv) != 2:
die(
"usage: process-github-event "
"<event-json>"
)
with open(
sys.argv[1],
"r",
encoding="utf-8",
) as f:
event = json.load(
f
)
if event.get(
"action"
) != "opened":
die(
"only opened issue "
"events are accepted"
)
repo = (
event.get(
"repository"
)
or {}
).get(
"full_name"
)
if repo != ALLOWED_REPO:
die(
"unexpected repository"
)
issue = event.get(
"issue"
) or {}
title = issue.get(
"title"
) or ""
if not title.startswith(
"[SVMAIL] "
):
die(
"issue is not "
"an SVMAIL job"
)
issue_number = issue.get(
"number"
)
if not isinstance(
issue_number,
int,
):
die(
"invalid issue number"
)
try:
payload = json.loads(
issue.get(
"body"
) or ""
)
except json.JSONDecodeError as exc:
die(
"issue body is not "
"valid JSON: "
f"{exc}"
)
request = {
"type":
"send",
"version":
payload.get(
"version"
),
"repo":
repo,
"issue_number":
issue_number,
"job_id":
f"{repo}#{issue_number}",
"approved_by":
payload.get(
"approved_by"
),
"to":
payload.get(
"to"
),
"subject":
payload.get(
"subject"
),
"body":
payload.get(
"body"
),
"reply_message_id":
payload.get(
"reply_message_id"
),
}
wire = (
json.dumps(
request,
ensure_ascii=False,
separators=(
",",
":"
),
)
+ "\n"
).encode(
"utf-8"
)
with socket.socket(
socket.AF_UNIX,
socket.SOCK_STREAM,
) as s:
s.settimeout(
60
)
s.connect(
SOCKET_PATH
)
s.sendall(
wire
)
response = bytearray()
while b"\n" not in response:
chunk = s.recv(
65536
)
if not chunk:
break
response.extend(
chunk
)
try:
result = json.loads(
bytes(response)
.split(
b"\n",
1
)[0]
.decode(
"utf-8"
)
)
except Exception as exc:
die(
"invalid daemon response: "
f"{exc}"
)
if not result.get(
"ok"
):
die(
result.get(
"error",
"unknown daemon error",
)
)
print(
"SVMAIL OK "
f"status={result.get('status')} "
"message_id="
f"{result.get('message_id', '-')}"
)
Code: Select all
<GITHUB_USER>/switch-vision-mail-queue
16. Lock down the code
Code: Select all
chown -R root:root \
/opt/switch-vision-mailer
chmod 755 \
/opt/switch-vision-mailer
chmod 755 \
/opt/switch-vision-mailer/bin
chmod 755 \
/opt/switch-vision-mailer/bin/mailer-daemon
chmod 755 \
/opt/switch-vision-mailer/bin/process-github-event
17. Create the isolated mailer service user
The GitHub runner should NOT have direct access to the Mailcow password.
Create a second system account for the actual sender:
Code: Select all
RUNNER_GROUP="$(id -gn switchvision)"
id svmailer >/dev/null 2>&1 || \
useradd \
--system \
--no-create-home \
--home-dir /nonexistent \
--shell /usr/sbin/nologin \
--gid "$RUNNER_GROUP" \
svmailer
Code: Select all
id svmailer
18. Install the systemd service
Create:
Code: Select all
nano /etc/systemd/system/switch-vision-mailer.service
Code: Select all
[Unit]
Description=Switch Vision Mailer
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=svmailer
Group=switchvision
EnvironmentFile=/etc/switch-vision-mailer/config.env
LoadCredential=mailcow.pass:/etc/switch-vision-mailer/mailcow.pass
ExecStart=/opt/switch-vision-mailer/bin/mailer-daemon
Restart=on-failure
RestartSec=2
RuntimeDirectory=switch-vision-mailer
RuntimeDirectoryMode=0750
StateDirectory=switch-vision-mailer
StateDirectoryMode=0750
UMask=0007
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]
WantedBy=multi-user.target
Code: Select all
switchvision
Reload systemd:
Code: Select all
systemctl daemon-reload
Code: Select all
systemctl enable --now \
switch-vision-mailer
Code: Select all
systemctl \
--no-pager \
--full \
status switch-vision-mailer
Code: Select all
Active: active (running)
Code: Select all
Switch Vision mailer listening on
/run/switch-vision-mailer/mailer.sock
19. Test the local mailer without sending mail
Run:
Code: Select all
sudo -u switchvision python3 - <<'PY'
import socket
sock = (
"/run/switch-vision-mailer/mailer.sock"
)
with socket.socket(
socket.AF_UNIX,
socket.SOCK_STREAM,
) as s:
s.connect(
sock
)
s.sendall(
b'{"type":"health"}\n'
)
print(
s.recv(
4096
)
.decode()
.strip()
)
PY
Code: Select all
{"ok":true,"status":"healthy"}
20. Perform the first end-to-end test
For the first real test I recommend sending the message back to the same mailbox.
Create a new issue in the PRIVATE queue repository.
Title:
Code: Select all
[SVMAIL] Mail bridge end-to-end test
Code: Select all
{
"version": 1,
"approved_by": "<GITHUB_USER>",
"to": "switch-vision@example.com",
"subject": "Mail bridge end-to-end test",
"body": "Hi,\n\nThis is the first end-to-end test of the private mail bridge.",
"reply_message_id": null
}
Code: Select all
GitHub issue
↓
GitHub Action
↓
Self-hosted runner
↓
process-github-event
↓
Local Unix socket
↓
mailer-daemon
↓
SMTP
↓
Mailcow
The runner should report something similar to:
Code: Select all
SVMAIL OK
status=sent
message_id=<generated-message-id@example.com>
Code: Select all
Mail job completed successfully.
21. Verify the result
Check the mailbox.
You should see the test email arrive.
Confirm:
Code: Select all
From:
Switch Vision <switch-vision@example.com>
The same message should also exist there.
The disclosure footer should have been automatically added:
Code: Select all
This email was drafted with the assistance of ChatGPT and reviewed by zemerdon before sending.
Duplicate-send protection
The mailer stores only a small local SQLite state database:
Code: Select all
/var/lib/switch-vision-mailer/state.db
Code: Select all
GitHub job ID
Payload hash
Recipient
Subject
Message-ID
State
Timestamp
Possible states include:
Code: Select all
sending
smtp_sent
sent
send_uncertain
If SMTP completed but the IMAP Sent append failed, the system can repair the Sent copy without resending the email.
Why send_uncertain exists
SMTP has an awkward edge case:
A network connection can fail after the SMTP server has accepted the message but before the client sees the final acknowledgement.
In that situation it is impossible to know safely whether the message was delivered.
The mailer therefore marks it:
Code: Select all
send_uncertain
This is deliberately conservative because duplicate support emails are worse than requiring manual investigation.
Useful diagnostics
Mailer status:
Code: Select all
systemctl status \
switch-vision-mailer
Code: Select all
journalctl \
-u switch-vision-mailer \
--since=-30m
Code: Select all
cd /home/switchvision/actions-runner
./svc.sh status
Code: Select all
ls -l \
/run/switch-vision-mailer/
Code: Select all
sqlite3 \
/var/lib/switch-vision-mailer/state.db \
'SELECT job_id,state,recipient,subject,message_id FROM jobs;'
Troubleshooting: SMTP 535 authentication failed
During my setup IMAP authentication worked but SMTP returned:
Code: Select all
535 5.7.8 Error: authentication failed
Code: Select all
SASL PLAIN authentication failed
SASL LOGIN authentication failed
The issue was that SMTP access had not been enabled for the Mailcow app password.
Make sure the app password has:
Code: Select all
IMAP = enabled
SMTP = enabled
Troubleshooting Mailcow authentication logs
On the Mailcow host:
Code: Select all
cd /path/to/mailcow-dockerized
docker compose logs \
--since=5m \
postfix-mailcow \
dovecot-mailcow \
| grep -iE \
'auth|sasl|fail|535'
Security design
There are several intentional security boundaries in this setup.
The GitHub repository is private
Outgoing mail bodies are not publicly visible.
Mailcow credentials remain local
The app password exists only on the mailer LXC:
Code: Select all
/etc/switch-vision-mailer/mailcow.pass
Code: Select all
GitHub
GitHub Actions secrets
ChatGPT
GitHub issues
Repository files
The SMTP/IMAP credential is loaded into the separate svmailer systemd service using:
Code: Select all
LoadCredential=
Code: Select all
/run/switch-vision-mailer/mailer.sock
The queue job cannot choose an arbitrary From address.
The local daemon generates the From header itself.
Header injection is rejected
Recipients, subjects and Message-ID values containing CR/LF characters are rejected.
Only the intended private repository is accepted
The local daemon checks:
Code: Select all
ALLOWED_REPO
Linux SO_PEERCRED is used to verify the local caller.
No Internet-facing listener exists
The mailer uses a local Unix socket instead of opening a TCP port.
The service is sandboxed
The systemd unit uses:
Code: Select all
NoNewPrivileges
ProtectSystem
ProtectHome
PrivateTmp
PrivateDevices
ProtectKernelTunables
ProtectKernelModules
ProtectControlGroups
RestrictAddressFamilies
One privacy consideration
The outgoing email body exists inside the private GitHub issue used as the queue job.
Closing the issue does NOT delete that content.
Mailcow remains the real mailbox and Sent archive, but GitHub also retains the private queue issue unless you remove or scrub it.
If this matters for your environment, the workflow can be extended so that after successful delivery it replaces the issue body with something like:
Code: Select all
Mail sent successfully.
Message-ID:
<...>
Original content removed after processing.
Attachments
The version documented here is intentionally TEXT ONLY.
I would recommend getting plain messages stable first before adding attachments.
If attachment support is added later, I would avoid embedding large attachments directly into GitHub issues.
A safer design is to have the mailer temporarily fetch an authorised attachment, send it, append the final MIME message to Mailcow Sent, and immediately remove the temporary local file.
Normal day-to-day use
Once everything is working the human workflow becomes very simple:
Code: Select all
User:
"Send that reply."
ChatGPT:
Creates a private [SVMAIL] GitHub issue.
GitHub:
Dispatches it to the private runner.
Mailer:
Validates the job.
Mailer:
Sends through Mailcow.
Mailer:
Copies the message into Mailcow Sent.
GitHub:
Marks the job complete and closes the issue.
Final architecture
Code: Select all
┌─────────────────────┐
│ ChatGPT │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ PRIVATE GitHub Repo │
│ [SVMAIL] Issue │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ Self-hosted Runner │
│ switchvision user │
└──────────┬──────────┘
│
Local Unix Socket
│
▼
┌─────────────────────┐
│ Mailer Daemon │
│ svmailer user │
│ │
│ Fixed sender │
│ Duplicate protection│
│ Header validation │
└───────┬───────┬─────┘
│ │
SMTP 587 │ │ IMAP 993
│ │
▼ ▼
┌─────────────────────┐
│ Mailcow │
│ │
│ Outbound SMTP │
│ Sent folder │
└─────────────────────┘
Result
The finished bridge gives me a secure and surprisingly simple way to combine ChatGPT-assisted support email with my own Mailcow infrastructure while retaining control of the real sender identity and keeping Mailcow as the authoritative mail system.
No public mail API.
No inbound firewall opening.
No Mailcow password stored at GitHub.
No arbitrary sender impersonation.
And every successful message still appears normally in SOGo Sent.