Build a Secure ChatGPT → GitHub → Mailcow Email Bridge

Post Reply
zemerdon
Site Admin
Posts: 398
Joined: Mon Jan 23, 2023 8:13 pm

Build a Secure ChatGPT → GitHub → Mailcow Email Bridge

Post by zemerdon »

How-To: 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
At the same time, the exact outgoing message is appended to the Mailcow Sent folder:

Code: Select all

Mailer
   ↓
Mailcow IMAP
   ↓
Sent
   ↓
SOGo
In my case Mailcow remains the authoritative mailbox.

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
Replace the example hostname, account and GitHub username with your own values.


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 Docker is required.

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
No inbound Internet firewall rule or port-forward is required.


2. Update Debian and install packages

Log into the new container as root.

Code: Select all

apt update
apt full-upgrade -y
Install the basic tools:

Code: Select all

apt install -y \
  ca-certificates \
  curl \
  git \
  python3 \
  python3-venv \
  python3-pip \
  jq \
  openssl \
  unzip \
  sqlite3 \
  lsof \
  socat \
  netcat-openbsd
Set the timezone if required:

Code: Select all

timedatectl set-timezone Australia/Sydney
Check it:

Code: Select all

timedatectl

3. Create the GitHub runner user

Create a dedicated account:

Code: Select all

useradd \
  --create-home \
  --shell /bin/bash \
  switchvision
Create the directories used by the mailer:

Code: Select all

install -d -o switchvision -g switchvision -m 750 \
  /opt/switch-vision-mailer \
  /var/lib/switch-vision-mailer
Create the protected configuration directory:

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
Test SMTP connectivity:

Code: Select all

openssl s_client \
  -connect mail.example.com:587 \
  -starttls smtp \
  -servername mail.example.com </dev/null
Test IMAP:

Code: Select all

openssl s_client \
  -connect mail.example.com:993 \
  -servername mail.example.com </dev/null
At this stage we are only testing connectivity and TLS.

Do not enter mailbox passwords into these commands.


5. Create the private GitHub repository

Create:

Code: Select all

<GITHUB_USER>/switch-vision-mail-queue
Set visibility to:

Code: Select all

PRIVATE
Initialize it with a README so the repository has a main branch.

[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
Choose:

Code: Select all

Linux
x64
GitHub generates the current runner download and registration commands.

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
Paste GitHub's generated download commands.

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
Exit back to root:

Code: Select all

exit
Install the runner as a service:

Code: Select all

cd /home/switchvision/actions-runner

./svc.sh install switchvision
./svc.sh start
./svc.sh status
GitHub should now show:

Code: Select all

switch-vision-mailer
Idle
That means GitHub can dispatch jobs to the LXC.


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
to the repositories available to the ChatGPT GitHub connection.

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
The app password needs at least:

Code: Select all

IMAP access: enabled
SMTP access: enabled
[warning]
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
Enter the password without echoing it:

Code: Select all

read -rsp "Paste Mailcow app password: " SVPASS
echo
Save it:

Code: Select all

umask 077

printf '%s' "$SVPASS" \
  > /etc/switch-vision-mailer/mailcow.pass

unset SVPASS
Lock permissions:

Code: Select all

chown root:root \
  /etc/switch-vision-mailer/mailcow.pass

chmod 600 \
  /etc/switch-vision-mailer/mailcow.pass
Check the file exists:

Code: Select all

ls -l /etc/switch-vision-mailer/mailcow.pass
Do NOT cat the password file.


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
Expected result:

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
Expected result:

Code: Select all

IMAP AUTH: OK

12. Create the mailer configuration

Create:

Code: Select all

nano /etc/switch-vision-mailer/config.env
Example:

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
Lock it:

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
with:

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
Only issues beginning with:

Code: Select all

[SVMAIL]
can trigger a mail job.


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
Use:

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
Use:

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', '-')}"
)
Replace:

Code: Select all

<GITHUB_USER>/switch-vision-mail-queue
with the actual repository.


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
Check it:

Code: Select all

id svmailer

18. Install the systemd service

Create:

Code: Select all

nano /etc/systemd/system/switch-vision-mailer.service
Use:

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
If your runner user's primary group is not:

Code: Select all

switchvision
replace the Group value accordingly.

Reload systemd:

Code: Select all

systemctl daemon-reload
Enable and start:

Code: Select all

systemctl enable --now \
  switch-vision-mailer
Check it:

Code: Select all

systemctl \
  --no-pager \
  --full \
  status switch-vision-mailer
Expected:

Code: Select all

Active: active (running)
and something similar to:

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
Expected result:

Code: Select all

{"ok":true,"status":"healthy"}
This health test does NOT send email.


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
Issue body:

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
}
Opening the issue should trigger:

Code: Select all

GitHub issue
   ↓
GitHub Action
   ↓
Self-hosted runner
   ↓
process-github-event
   ↓
Local Unix socket
   ↓
mailer-daemon
   ↓
SMTP
   ↓
Mailcow
After successful SMTP submission, the exact same message is appended via IMAP to the Mailcow Sent folder.

The runner should report something similar to:

Code: Select all

SVMAIL OK
status=sent
message_id=<generated-message-id@example.com>
The GitHub issue should then receive:

Code: Select all

Mail job completed successfully.
and automatically close.


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>
Then check Sent.

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
It records things such as:

Code: Select all

GitHub job ID
Payload hash
Recipient
Subject
Message-ID
State
Timestamp
The body of the email is NOT stored in this database.

Possible states include:

Code: Select all

sending
smtp_sent
sent
send_uncertain
If a GitHub workflow is accidentally rerun after a confirmed delivery, the mailer recognises the original job ID and refuses to send another copy.

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
and refuses to automatically resend.

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
Mailer logs:

Code: Select all

journalctl \
  -u switch-vision-mailer \
  --since=-30m
Runner service:

Code: Select all

cd /home/switchvision/actions-runner

./svc.sh status
Unix socket:

Code: Select all

ls -l \
  /run/switch-vision-mailer/
SQLite state:

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
Mailcow/Postfix showed:

Code: Select all

SASL PLAIN authentication failed
SASL LOGIN authentication failed
The app password was valid because IMAP worked.

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
Then test SMTP again.


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'
This is much more useful than repeatedly changing passwords without knowing why authentication is failing.


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
It is not stored in:

Code: Select all

GitHub
GitHub Actions secrets
ChatGPT
GitHub issues
Repository files
The GitHub runner cannot directly read the password

The SMTP/IMAP credential is loaded into the separate svmailer systemd service using:

Code: Select all

LoadCredential=
The runner communicates with the service through:

Code: Select all

/run/switch-vision-mailer/mailer.sock
The sender address is fixed

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
Only the expected runner UID can access the daemon

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.
That would substantially reduce long-term mail content stored in GitHub.


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.
For Switch Vision this means contributor/support email can be drafted and reviewed with ChatGPT while still being delivered through the real project mailbox rather than a personal Gmail account.


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.

😎
Post Reply