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)

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.

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:
- Residential IP addresses are often blacklisted by email providers, resulting in deliverability issues.
- 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:
- Self-host a Mailu server to store emails and have a nice web GUI
- Set up Mailgun as an “external SMTP relay” for sending emails
- 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:
- Kubernetes: v1.33.4+k3s1
- ArgoCD: v3.0.6+db93798
- Domain: junyi.me, managed by Cloudflare
To be set up:
- Mailu: helm chart version 2.5.1
- 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.
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).

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.

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:
- Listen for incoming HTTP POST requests from Mailgun
- Extract the email content from the request (from, to, subject, body, attachments)
- 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.