mailserver: add broken sogo

This commit is contained in:
Ruben Hensen
2026-03-15 10:58:50 +01:00
parent 9581f1bb3e
commit c8055467d6
21 changed files with 601 additions and 44 deletions
@@ -0,0 +1,6 @@
---
certbot_email: "admin@rubenhensen.nl"
certbot_domains:
- "mail.rubenhensen.nl"
- "stalwart.rubenhensen.nl"
certbot_webroot: "/var/www/acme"
@@ -0,0 +1,87 @@
---
- name: Install certbot
ansible.builtin.apt:
name: certbot
state: present
- name: Check if certificate already exists
ansible.builtin.stat:
path: "/etc/letsencrypt/live/{{ certbot_domains[0] }}/fullchain.pem"
register: certbot_cert
- name: Stop Stalwart for initial cert issuance
ansible.builtin.service:
name: stalwart
state: stopped
when: not certbot_cert.stat.exists
- name: Obtain certificate (standalone)
ansible.builtin.command:
cmd: >
certbot certonly --standalone
--non-interactive
--agree-tos
--email {{ certbot_email }}
{% for domain in certbot_domains %}-d {{ domain }} {% endfor %}
when: not certbot_cert.stat.exists
changed_when: true
- name: Start Stalwart after cert issuance
ansible.builtin.service:
name: stalwart
state: started
when: not certbot_cert.stat.exists
- name: Grant stalwart read access to certs
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- /etc/letsencrypt/live
- /etc/letsencrypt/archive
- name: Deploy Stalwart admin password for renewal hook
ansible.builtin.copy:
dest: /etc/letsencrypt/.stalwart-admin-pass
content: "{{ stalwart_fallback_admin_password }}"
owner: root
group: root
mode: "0600"
- name: Deploy certbot renewal hook
ansible.builtin.copy:
dest: /etc/letsencrypt/renewal-hooks/deploy/stalwart.sh
content: |
#!/bin/bash
DOMAIN="{{ certbot_domains[0] }}"
PASS=$(cat /etc/letsencrypt/.stalwart-admin-pass)
python3 -c "
import json, urllib.request
cert = open('/etc/letsencrypt/live/$DOMAIN/fullchain.pem').read()
key = open('/etc/letsencrypt/live/$DOMAIN/privkey.pem').read()
body = json.dumps([{'type':'insert','assert_empty':False,'values':[
['certificate.default.cert', cert],
['certificate.default.private-key', key],
]}]).encode()
import base64
auth = base64.b64encode(('admin:' + open('/etc/letsencrypt/.stalwart-admin-pass').read().strip()).encode()).decode()
req = urllib.request.Request('http://127.0.0.1:8080/api/settings', data=body,
headers={'Content-Type':'application/json','Authorization':'Basic ' + auth}, method='POST')
urllib.request.urlopen(req)
req = urllib.request.Request('http://127.0.0.1:8080/api/reload',
headers={'Authorization':'Basic ' + auth})
urllib.request.urlopen(req)
"
mode: "0755"
- name: Ensure certbot renewal timer is enabled
ansible.builtin.systemd:
name: certbot.timer
enabled: true
state: started
@@ -0,0 +1,7 @@
---
nginx_server_name: "mail.rubenhensen.nl"
nginx_stalwart_server_name: "stalwart.rubenhensen.nl"
nginx_tls_cert: "/etc/letsencrypt/live/mail.rubenhensen.nl/fullchain.pem"
nginx_tls_key: "/etc/letsencrypt/live/mail.rubenhensen.nl/privkey.pem"
nginx_stalwart_backend: "127.0.0.1:8443"
nginx_sogo_backend: "127.0.0.1:20000"
@@ -0,0 +1,5 @@
---
- name: reload nginx
ansible.builtin.systemd:
name: nginx
state: reloaded
@@ -0,0 +1,31 @@
---
- name: Install Nginx
ansible.builtin.apt:
name: nginx
state: present
- name: Remove default site
ansible.builtin.file:
path: /etc/nginx/sites-enabled/default
state: absent
notify: reload nginx
- name: Deploy mail site config
ansible.builtin.template:
src: mail.conf.j2
dest: /etc/nginx/sites-available/mail.conf
mode: "0644"
notify: reload nginx
- name: Enable mail site
ansible.builtin.file:
src: /etc/nginx/sites-available/mail.conf
dest: /etc/nginx/sites-enabled/mail.conf
state: link
notify: reload nginx
- name: Enable and start Nginx
ansible.builtin.systemd:
name: nginx
enabled: true
state: started
@@ -0,0 +1,113 @@
server {
listen 80;
listen [::]:80;
server_name {{ nginx_server_name }};
# ACME challenge
location /.well-known/acme-challenge/ {
root /var/www/acme;
}
# Redirect everything else to HTTPS
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name {{ nginx_server_name }};
ssl_certificate {{ nginx_tls_cert }};
ssl_certificate_key {{ nginx_tls_key }};
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# SOGo
location ^~ /SOGo {
proxy_pass http://{{ nginx_sogo_backend }};
proxy_redirect http://{{ nginx_sogo_backend }} /;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header x-webobjects-server-protocol HTTP/1.0;
proxy_set_header x-webobjects-remote-host $remote_addr;
proxy_set_header x-webobjects-server-name $server_name;
proxy_set_header x-webobjects-server-url $scheme://$host;
proxy_set_header x-webobjects-server-port $server_port;
proxy_buffer_size 128k;
proxy_buffers 64 512k;
proxy_busy_buffers_size 512k;
client_max_body_size 100m;
}
# SOGo static resources
location ^~ /SOGo.woa/WebServerResources/ {
alias /usr/lib/GNUstep/SOGo/WebServerResources/;
}
location /SOGo/WebServerResources/ {
alias /usr/lib/GNUstep/SOGo/WebServerResources/;
}
# Microsoft AutoDiscover / ActiveSync
location ^~ /Microsoft-Server-ActiveSync {
proxy_pass http://{{ nginx_sogo_backend }}/SOGo/Microsoft-Server-ActiveSync;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_connect_timeout 360;
proxy_send_timeout 360;
proxy_read_timeout 360;
}
# CalDAV/CardDAV well-known
location /.well-known/caldav {
return 301 https://$host/SOGo/dav;
}
location /.well-known/carddav {
return 301 https://$host/SOGo/dav;
}
# Default: SOGo
location / {
return 301 https://$host/SOGo;
}
}
# Stalwart web admin
server {
listen 80;
listen [::]:80;
server_name {{ nginx_stalwart_server_name }};
location /.well-known/acme-challenge/ {
root /var/www/acme;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name {{ nginx_stalwart_server_name }};
ssl_certificate {{ nginx_tls_cert }};
ssl_certificate_key {{ nginx_tls_key }};
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
proxy_pass https://{{ nginx_stalwart_backend }};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}
@@ -0,0 +1,11 @@
---
sogo_db_name: "sogo"
sogo_db_user: "sogo"
sogo_db_password: "{{ vault_sogo_db_password }}"
sogo_imap_server: "127.0.0.1"
sogo_smtp_server: "127.0.0.1"
sogo_mail_domain: "rubenhensen.nl"
sogo_time_zone: "Europe/Amsterdam"
sogo_page_title: "Mail - rubenhensen.nl"
@@ -0,0 +1,5 @@
---
- name: restart sogo
ansible.builtin.systemd:
name: sogo
state: restarted
@@ -0,0 +1,88 @@
---
- name: Install PostgreSQL
ansible.builtin.apt:
name:
- postgresql
- postgresql-client
- python3-psycopg2
state: present
- name: Ensure PostgreSQL is running
ansible.builtin.systemd:
name: postgresql
enabled: true
state: started
- name: Create SOGo database user
become: true
become_user: postgres
community.postgresql.postgresql_user:
name: "{{ sogo_db_user }}"
password: "{{ sogo_db_password }}"
- name: Create SOGo database
become: true
become_user: postgres
community.postgresql.postgresql_db:
name: "{{ sogo_db_name }}"
owner: "{{ sogo_db_user }}"
- name: Grant SOGo user read access to Stalwart accounts
become: true
become_user: postgres
community.postgresql.postgresql_privs:
database: "stalwart"
roles: "{{ sogo_db_user }}"
type: table
objs: accounts,emails
privs: SELECT
- name: Download SOGo repository key
ansible.builtin.shell:
cmd: >
wget -qO- "https://keys.openpgp.org/vks/v1/by-fingerprint/74FFC6D72B925A34B5D356BDF8A27B36A6E2EAE9"
| gpg --dearmor > /etc/apt/trusted.gpg.d/sogo.gpg
creates: /etc/apt/trusted.gpg.d/sogo.gpg
- name: Add SOGo nightly repository
ansible.builtin.apt_repository:
repo: "deb https://packages.sogo.nu/nightly/5/ubuntu/ {{ ansible_facts['distribution_release'] }} {{ ansible_facts['distribution_release'] }}"
filename: sogo
state: present
- name: Install SOGo and dependencies
ansible.builtin.apt:
name:
- sogo
- sogo-activesync
- sope4.9-gdl1-postgresql
state: present
update_cache: true
- name: Disable Apache (installed as SOGo dependency, Nginx is used instead)
ansible.builtin.systemd:
name: apache2
enabled: false
state: stopped
failed_when: false
- name: Deploy SOGo configuration
ansible.builtin.template:
src: sogo.conf.j2
dest: /etc/sogo/sogo.conf
owner: sogo
group: sogo
mode: "0640"
notify: restart sogo
- name: Enable and start SOGo
ansible.builtin.systemd:
name: sogo
enabled: true
state: started
- name: Enable and start memcached
ansible.builtin.systemd:
name: memcached
enabled: true
state: started
@@ -0,0 +1,45 @@
{
SOGoTimeZone = "{{ sogo_time_zone }}";
SOGoPageTitle = "{{ sogo_page_title }}";
SOGoLanguage = "English";
SOGoMailDomain = "{{ sogo_mail_domain }}";
SOGoIMAPServer = "imaps://{{ sogo_imap_server }}:993/?tls=YES&tlsVerifyMode=none";
SOGoSMTPServer = "smtp://{{ sogo_smtp_server }}:587/?tls=YES&tlsVerifyMode=none";
SOGoSMTPAuthenticationType = "PLAIN";
SOGoIMAPAclConformsToIMAPExt = YES;
SOGoForceExternalLoginWithEmail = YES;
SOGoUserSources = (
{
type = sql;
id = directory;
viewURL = "postgresql://{{ sogo_db_user }}:{{ sogo_db_password }}@127.0.0.1:5432/stalwart/sogo_users";
canAuthenticate = YES;
isAddressBook = NO;
LoginFieldNames = ("mail");
userPasswordAlgorithm = "sha512-crypt";
}
);
SOGoProfileURL = "postgresql://{{ sogo_db_user }}:{{ sogo_db_password }}@127.0.0.1:5432/{{ sogo_db_name }}/sogo_user_profile";
OCSFolderInfoURL = "postgresql://{{ sogo_db_user }}:{{ sogo_db_password }}@127.0.0.1:5432/{{ sogo_db_name }}/sogo_folder_info";
OCSSessionsFolderURL = "postgresql://{{ sogo_db_user }}:{{ sogo_db_password }}@127.0.0.1:5432/{{ sogo_db_name }}/sogo_sessions_folder";
OCSEMailAlarmsFolderURL = "postgresql://{{ sogo_db_user }}:{{ sogo_db_password }}@127.0.0.1:5432/{{ sogo_db_name }}/sogo_alarms_folder";
OCSStoreURL = "postgresql://{{ sogo_db_user }}:{{ sogo_db_password }}@127.0.0.1:5432/{{ sogo_db_name }}/sogo_store";
OCSAclURL = "postgresql://{{ sogo_db_user }}:{{ sogo_db_password }}@127.0.0.1:5432/{{ sogo_db_name }}/sogo_acl";
OCSCacheFolderURL = "postgresql://{{ sogo_db_user }}:{{ sogo_db_password }}@127.0.0.1:5432/{{ sogo_db_name }}/sogo_cache_folder";
SOGoAppointmentSendEMailNotifications = YES;
SOGoEnableEMailAlarms = YES;
SOGoMemcachedHost = "127.0.0.1";
SOGoCalendarDefaultRoles = (
PublicViewer,
ConfidentialDAccessor
);
SOGoSuperUsernames = ("admin");
SOGoFirstDayOfWeek = 1;
}
@@ -39,12 +39,12 @@ stalwart_server_listeners:
options:
tls.implicit: true
- name: "https"
bind: "[::]:443"
bind: "127.0.0.1:8443"
protocol: "http"
options:
tls.implicit: true
- name: "http"
bind: "[::]:8080"
bind: "127.0.0.1:8080"
protocol: "http"
# Stores
@@ -54,11 +54,24 @@ stalwart_stores:
options:
path: "{{ stalwart_data_path }}"
compression: "lz4"
- name: "postgresql"
type: "postgresql"
options:
host: "127.0.0.1"
port: 5432
database: "{{ stalwart_db_name }}"
user: "{{ stalwart_db_user }}"
password: "{{ stalwart_db_password }}"
# Directory
stalwart_directory_type: "internal"
stalwart_directory_type: "sql"
stalwart_directory_options:
store: "rocksdb"
store: "postgresql"
# Database
stalwart_db_name: "stalwart"
stalwart_db_user: "stalwart"
stalwart_db_password: "{{ vault_stalwart_db_password }}"
# Tracers
stalwart_tracers:
@@ -76,12 +89,9 @@ stalwart_tracers:
ansi: false
enable: true
# ACME / Let's Encrypt
stalwart_acme_enabled: true
stalwart_acme_directory: "https://acme-v02.api.letsencrypt.org/directory"
stalwart_acme_contact: "mailto:admin@rubenhensen.nl"
stalwart_acme_domains:
- "mail.rubenhensen.nl"
# TLS certificate (managed by certbot)
stalwart_tls_cert: "/etc/letsencrypt/live/{{ stalwart_server_hostname }}/fullchain.pem"
stalwart_tls_key: "/etc/letsencrypt/live/{{ stalwart_server_hostname }}/privkey.pem"
# Extra config sections (list of {name, options} dicts)
stalwart_additional_configs: []
@@ -0,0 +1,33 @@
CREATE TABLE IF NOT EXISTS accounts (
name VARCHAR(255) PRIMARY KEY,
password VARCHAR(255) NOT NULL,
description VARCHAR(255) DEFAULT '',
type VARCHAR(20) NOT NULL DEFAULT 'individual',
email VARCHAR(255) NOT NULL,
quota INTEGER DEFAULT 0,
active BOOLEAN DEFAULT true
);
CREATE TABLE IF NOT EXISTS group_members (
name VARCHAR(255) NOT NULL,
member_of VARCHAR(255) NOT NULL,
PRIMARY KEY (name, member_of)
);
CREATE TABLE IF NOT EXISTS emails (
name VARCHAR(255) NOT NULL,
address VARCHAR(255) NOT NULL,
type VARCHAR(20) NOT NULL DEFAULT 'primary',
PRIMARY KEY (name, address)
);
-- View for SOGo (expects c_ prefixed columns)
CREATE OR REPLACE VIEW sogo_users AS
SELECT
name AS c_uid,
name AS c_name,
password AS c_password,
description AS c_cn,
email AS mail
FROM accounts
WHERE active = true;
@@ -19,4 +19,55 @@
ansible.builtin.service:
name: "{{ stalwart_service_name }}"
state: "{{ stalwart_service_state }}"
enabled: "{{ stalwart_service_enabled }}"
enabled: "{{ stalwart_service_enabled }}"
- name: Flush handlers to ensure Stalwart is running
ansible.builtin.meta: flush_handlers
- name: Wait for Stalwart API
ansible.builtin.wait_for:
port: 8080
host: 127.0.0.1
delay: 2
timeout: 30
- name: Read TLS certificate
ansible.builtin.slurp:
src: "{{ stalwart_tls_cert }}"
register: tls_cert_content
- name: Read TLS private key
ansible.builtin.slurp:
src: "{{ stalwart_tls_key }}"
register: tls_key_content
- name: Push TLS certificate to Stalwart
ansible.builtin.uri:
url: "http://127.0.0.1:8080/api/settings"
method: POST
user: "{{ stalwart_fallback_admin_login }}"
password: "{{ stalwart_fallback_admin_password }}"
force_basic_auth: true
body_format: json
body:
- type: "insert"
assert_empty: false
values:
- - "certificate.default.cert"
- "{{ tls_cert_content.content | b64decode }}"
- - "certificate.default.private-key"
- "{{ tls_key_content.content | b64decode }}"
- - "certificate.default.default"
- "true"
- - "server.tls.certificate"
- "default"
status_code: [200, 204]
- name: Reload Stalwart to apply certificate
ansible.builtin.uri:
url: "http://127.0.0.1:8080/api/reload"
method: GET
user: "{{ stalwart_fallback_admin_login }}"
password: "{{ stalwart_fallback_admin_password }}"
force_basic_auth: true
status_code: [200, 204]
@@ -21,6 +21,37 @@
group: "{{ stalwart_system_group }}"
mode: "0755"
- name: Create Stalwart database user
become: true
become_user: postgres
community.postgresql.postgresql_user:
name: "{{ stalwart_db_user }}"
password: "{{ stalwart_db_password }}"
when: stalwart_directory_type == "sql"
- name: Create Stalwart database
become: true
become_user: postgres
community.postgresql.postgresql_db:
name: "{{ stalwart_db_name }}"
owner: "{{ stalwart_db_user }}"
when: stalwart_directory_type == "sql"
- name: Deploy database schema
ansible.builtin.copy:
src: schema.sql
dest: /tmp/stalwart-schema.sql
mode: "0644"
when: stalwart_directory_type == "sql"
- name: Apply database schema
become: true
become_user: postgres
ansible.builtin.command:
cmd: psql -d {{ stalwart_db_name }} -f /tmp/stalwart-schema.sql
changed_when: false
when: stalwart_directory_type == "sql"
- name: Check if already installed
ansible.builtin.stat:
path: "{{ stalwart_executable_path }}"
@@ -1,9 +1,6 @@
[server]
hostname = "{{ stalwart_server_hostname }}"
max-connections = {{ stalwart_server_max_connections }}
{% if stalwart_acme_enabled %}
tls.certificate = "acme"
{% endif %}
{% for listener in stalwart_server_listeners %}
[server.listener.{{ listener.name }}]
@@ -19,19 +16,6 @@ protocol = "{{ listener.protocol }}"
{% endfor %}
{% endfor %}
{% if stalwart_acme_enabled %}
[certificate.acme]
type = "acme"
provider = "letsencrypt"
domains = [{% for d in stalwart_acme_domains %}"{{ d }}"{% if not loop.last %}, {% endif %}{% endfor %}]
[acme.letsencrypt]
directory = "{{ stalwart_acme_directory }}"
contact = ["{{ stalwart_acme_contact }}"]
challenge = "tls-alpn-01"
default = true
{% endif %}
[storage]
data = "{{ stalwart_storage_data }}"
fts = "{{ stalwart_storage_fts }}"
@@ -62,6 +46,21 @@ type = "{{ stalwart_directory_type }}"
{%- endif %}
{% endfor %}
{% if stalwart_directory_type == "sql" %}
columns.secret = "password"
columns.description = "description"
columns.email = "email"
columns.quota = "quota"
columns.class = "type"
[store.postgresql.query]
name = "SELECT name, type, password, description, email, quota FROM accounts WHERE name = $1 AND active = true"
members = "SELECT member_of FROM group_members WHERE name = $1"
recipients = "SELECT name FROM emails WHERE address = $1"
emails = "SELECT address FROM emails WHERE name = $1 ORDER BY type DESC, address ASC"
secrets = "SELECT password FROM accounts WHERE name = $1 AND active = true"
{% endif %}
{% for tracer in stalwart_tracers %}
[tracer.{{ tracer.type }}]
@@ -8,4 +8,4 @@ ufw_allowed_ports:
- { port: "587", proto: "tcp", comment: "SMTP submission (STARTTLS)" }
- { port: "993", proto: "tcp", comment: "IMAP (implicit TLS)" }
- { port: "443", proto: "tcp", comment: "HTTPS (web admin + JMAP)" }
- { port: "8080", proto: "tcp", comment: "HTTP (certbot + redirect)" }
- { port: "80", proto: "tcp", comment: "HTTP (ACME + redirect)" }