Featured image of post An attempt at self-hosting mail server

An attempt at self-hosting mail server

Recently as I was implementing the “contact me” functionality for my personal website (https://junyi.me/), I had the thought of hosting my own email server. I was aware that self-hosting an email server has a lot of limitations on residential IP addresses, but I just wanted to see how far I could go.

Objectives

I wanted a contact form for my personal website that allows visitors to send me messages. (completed here)

Contact form

Upon submission, it uses would send an email to the visitor from my personal domain (noreply@junyi.me). I also receive a Slack notification with the message content.

Email

So I would want a way to send/receive emails with my visitors through an “@junyi.me” email address, both with SDK and web GUI.

Limitations and compromises

As a home labber, the first option I looked into was to self-host an email server on my home network. However, I was also aware that a self-hosted mail server had the following limitations:

  1. Residential IP addresses are often blacklisted by email providers, resulting in deliverability issues.
  2. Many ISPs (including mine) block port 25, which is mandatory for receiving emails.

In short, the self-hosted email server would have problem both sending and receiving emails reliably.

Therefore I decided to use a hybrid approach with Mailgun:

  1. Self-host a Mailu server to store emails and have a nice web GUI
  2. Set up Mailgun as an “external SMTP relay” for sending emails
  3. Use Mailgun Routes to forward incoming emails to my Mailu server

This way I can store all my emails locally while leveraging Mailgun’s infrastructure for sending and receiving emails.

Environment

Currently have:

  1. Kubernetes: v1.33.4+k3s1
  2. ArgoCD: v3.0.6+db93798
  3. Domain: junyi.me, managed by Cloudflare

To be set up:

  1. Mailu: helm chart version 2.5.1
  2. Mailgun: free tier

Set up Mailgun

I created a free Mailgun account and added my domain “junyi.me” as a sending domain, and then followed the instructions to add the required DNS records (SPF, DKIM, etc) in Cloudflare.

info

There is a 3000 emails/month sending limit on the free tier.

In order to send emails through Mailgun’s SMTP relay, I created a SMTP credential on the dashboard (credentials needed for Mailu setup later).

Mailgun SMTP Credential

Setting up Mailu

For the self-hosted part, I chose Mailu. It is designed to run in Docker containers and has a nice web GUI for managing emails, which is great for my Kubernetes cluster.

I used ArgoCD to deploy helm chart for Mailu.

mailu.yml
  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
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: mailu
  namespace: argocd
  annotations:
    notifications.argoproj.io/subscribe.slack: production
spec:
  destination:
    namespace: mail
    server: https://kubernetes.default.svc
  project: default
  source:
    repoURL: https://mailu.github.io/helm-charts/
    chart: mailu
    targetRevision: 2.5.0
    helm:
      valuesObject:
        global:
          database:
            roundcube:
              database: mailu
              username: mailu
              existingSecret: mailu-db
              existingSecretPasswordKey: password

        domain: junyi.me
        hostnames:
          - mail.junyi.me

        # Subnet for pod network
        subnet: 10.42.0.0/16

        secretKey: "<redacted>"

        # Initial admin account - stored in secret mailu-init
        initialAccount:
          enabled: true
          username: jy
          domain: junyi.me
          existingSecret: mailu-init
          existingSecretPasswordKey: userPassword
          mode: ifmissing

        admin:
          enabled: true
          persistence:
            storageClass: csi-rbd-sc
            size: 5Gi
          extraEnvVars:
            - name: KUBERNETES_DNSSEC_CHECK
              value: "false"

        externalDatabase:
          enabled: true
          type: postgresql
          host: central-rw.postgres.svc.cluster.local
          existingSecret: mailu-db
          existingSecretDatabaseKey: db
          existingSecretUsernameKey: user
          existingSecretPasswordKey: password

        ingress:
          enabled: false

        tls:
          kind: notls

        persistence:
          single_pvc: false

        # External relay (Mailgun)
        externalRelay:
          host: "[smtp.mailgun.org]:587"
          existingSecret: mailu-relay
          usernameKey: user
          passwordKey: password

        # Message size limits
        limits:
          messageSizeLimitInMegabytes: 50

        # Components
        front:
          kind: Deployment
          hostPort:
            enabled: false
          externalService:
            enabled: false
          extraEnvVars:
            - name: TLS_FLAVOR
              value: "notls"
          persistence:
            storageClass: csi-rbd-sc
            size: 5Gi

        postfix:
          enabled: true
          persistence:
            storageClass: csi-rbd-sc
            size: 5Gi

        dovecot:
          enabled: true
          persistence:
            size: 50Gi

        rspamd:
          enabled: true
          persistence:
            storageClass: csi-rbd-sc
            size: 3Gi


        clamav:
          enabled: false

        webmail:
          enabled: true
          type: roundcube
          persistence:
            storageClass: csi-rbd-sc
            size: 3Gi

        webdav:
          enabled: false

        fetchmail:
          enabled: false

        oletools:
          enabled: false

        tika:
          enabled: false

        # Resource limits
        resources:
          front:
            limits:
              memory: 1Gi
              cpu: 500m
            requests:
              memory: 256Mi
              cpu: 200m
          admin:
            limits:
              memory: 512Mi
              cpu: 500m
            requests:
              memory: 256Mi
              cpu: 200m
          postfix:
            limits:
              memory: 1Gi
              cpu: 1000m
            requests:
              memory: 512Mi
              cpu: 200m
          dovecot:
            limits:
              memory: 1Gi
              cpu: 1000m
            requests:
              memory: 512Mi
              cpu: 500m
          rspamd:
            limits:
              memory: 1Gi
              cpu: 1000m
            requests:
              memory: 512Mi
              cpu: 500m
          webmail:
            limits:
              memory: 512Mi
              cpu: 500m
            requests:
              memory: 256Mi
              cpu: 200m

  syncPolicy:
    automated:
      prune: true
      selfHeal: true
  ignoreDifferences:
  - group: ""
    kind: ConfigMap
    name: mailu-envvars
    jsonPointers:
    - /data/PORTS
secrets.yml
 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
apiVersion: v1
kind: Secret
metadata:
  name: mailu-init
  namespace: mail
type: Opaque
stringData:
  userPassword: <redacted>
  secretKey: <redacted>

---

apiVersion: v1
kind: Secret
metadata:
  name: mailu-db
  namespace: mail
type: Opaque
stringData:
  db: mailu
  user: mailu
  password: <redacted>

---

# from "SMTP Credentials" on Mailgun dashboard
apiVersion: v1
kind: Secret
metadata:
  name: mailu-relay
  namespace: mail
type: Opaque
stringData:
  host: "smtp.mailgun.org"
  user: "postmaster@junyi.me"
  password: <redacted>
expose.yml
 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
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: junyi-me-prod
  namespace: mail
spec:
  secretName: junyi-me-production
  issuerRef:
    name: letsencrypt-production
    kind: ClusterIssuer
  dnsNames:
  - "mail.junyi.me"

---

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: mailu
  namespace: mail
  annotations:
    traefik.ingress.kubernetes.io/router.entrypoints: internal, internalsecure
spec:
  ingressClassName: traefik
  rules:
  - host: mail.i.junyi.me
    http:
      paths:
      - backend:
          service:
            name: mailu-front
            port:
              name: http
        path: /
        pathType: Prefix
  tls:
  - hosts:
    - mail.junyi.me
    secretName: junyi-me-production

Receiving emails

Mailgun Routes

For receiving emails, since port 25 was blocked by my ISP, I had to go through Mailgun’s routing feature.

Mailgun Routes

It tells Mailgun to forward all emails sent to “.*@junyi.me” to HTTP endpoint “https://mailhook.junyi.me/", which I set up next.

Custom Mailgun webhook receiver

Since Mailgun would forward incoming emails as HTTP POST requests and Mailu only understands standard SMTP, I set up a bridging service to receive the HTTP requests and translate them into SMTP emails for Mailu. It turns out a simple Python script does the job. Source code available here: mailgun2smtp

Essentially it does this:

  1. Listen for incoming HTTP POST requests from Mailgun
  2. Extract the email content from the request (from, to, subject, body, attachments)
  3. Use SMTP to send the email to Mailu server

I set up a pipeline to build it into a container image, and deployed it to my Kubernetes cluster.

forward.yml
 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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
apiVersion: v1
kind: Secret
metadata:
  name: mailgun
  namespace: mail
stringData:
  # to verify the requests are from Mailgun
  # https://app.mailgun.com/mg/sending/junyi.me/webhooks
  MAILGUN_SIGNING_KEY: <redacted>

---

apiVersion: apps/v1
kind: Deployment
metadata:
  name: mailgun-bridge
  namespace: mail
spec:
  replicas: 1
  selector:
    matchLabels:
      app: mailgun-bridge
  template:
    metadata:
      labels:
        app: mailgun-bridge
    spec:
      imagePullSecrets:
        - name: junyime
      containers:
      - name: bridge
        image: regist.junyi.me/home/mailgun2smtp:prd
        imagePullPolicy: Always
        env:
          - name: MAILGUN_SIGNING_KEY
            valueFrom:
              secretKeyRef:
                name: mailgun
                key: MAILGUN_SIGNING_KEY
          - name: SMTP_HOST
            value: mailu-front
        ports:
        - containerPort: 8080
          name: http
        livenessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 10
          periodSeconds: 30
        resources:
          requests:
            memory: "128Mi"
            cpu: "100m"
          limits:
            memory: "256Mi"
            cpu: "200m"

---

apiVersion: v1
kind: Service
metadata:
  name: mailgun-bridge
  namespace: mail
spec:
  ports:
  - port: 80
    targetPort: 8080
    name: http
  selector:
    app: mailgun-bridge

---

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: mailgun-bridge
  namespace: mail
spec:
  ingressClassName: traefik
  rules:
  - host: mailhook.junyi.me
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: mailgun-bridge
            port:
              number: 80
  tls:
  - hosts:
    - mailhook.junyi.me
    secretName: junyi-me-production
mail-custom.yml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: mailu-custom
  namespace: argocd
  annotations:
    argocd-image-updater.argoproj.io/image-list: fwd=regist.junyi.me/home/mailgun2smtp:prd
    argocd-image-updater.argoproj.io/fwd.update-strategy: digest
    argocd-image-updater.argoproj.io/write-back-method: git
    argocd-image-updater.argoproj.io/write-back-target: kustomization
    notifications.argoproj.io/subscribe.slack: production
spec:
  destination:
    namespace: mail
    server: https://kubernetes.default.svc
  project: default
  source:
    path: kube/mail
    repoURL: git@git.junyi.me:home/homelab.git
    targetRevision: master
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

Conclusion

The initial objective has been achieved: I can now send and receive emails using my personal domain “junyi.me” through a self-hosted Mailu server. However, it is a little disappointing that I cannot self-host the entire email server due to reasons beyond my control.

Maybe someday I will try again with a business internet plan that is less restrictive, or SMTP becomes obsolete before that.

Built with Hugo
Theme Stack designed by Jimmy