Style newsletters and system e-mails like www.eom.dev
Catppuccin Mocha campaign template (default) and archive variant, kept in sync in the database; system e-mail header/footer overridden via --static-dir from a ConfigMap. PNG logo for mail clients. Tested in Mailpit (desktop, phone width; 87% CSS support, rest inlined). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,15 @@ The eom.dev playbook maps them from the Vault names `listmonk_postgresql_passwor
|
|||||||
1. In LDAP, create the posix account `news` with `mail: news@eom.dev` and add it to `cn=Postfix Users,ou=Postfix,ou=Services,dc=eom,dc=dev`. Its password is `listmonk_smtp_password`.
|
1. In LDAP, create the posix account `news` with `mail: news@eom.dev` and add it to `cn=Postfix Users,ou=Postfix,ou=Services,dc=eom,dc=dev`. Its password is `listmonk_smtp_password`.
|
||||||
2. Add the `listmonk` client to Dex (`ericomeehan.dex/playbooks/deploy-eom.yml`) and run that play.
|
2. Add the `listmonk` client to Dex (`ericomeehan.dex/playbooks/deploy-eom.yml`) and run that play.
|
||||||
|
|
||||||
|
## E-mail design
|
||||||
|
|
||||||
|
Newsletters and listmonk's system e-mails (opt-in confirmation, password reset and so on) use the Catppuccin Mocha palette and the eom.dev logo, matching www.eom.dev.
|
||||||
|
|
||||||
|
- `templates/email/campaign.html.j2` produces the **eom.dev** campaign template, which is the default for new campaigns, and an **eom.dev archive** variant without unsubscribe links. Both are kept in sync in the database on every run.
|
||||||
|
- `templates/email/base.html.j2` overrides only the shared system e-mail header and footer. It is mounted from a ConfigMap through `--static-dir`.
|
||||||
|
- The logo is a PNG (`www.eom.dev/static/email-logo.png`), because most mail clients don't show SVG.
|
||||||
|
- Set `listmonk_email_postal_address` to add a mailing address to every footer. US anti-spam law (CAN-SPAM) requires one in commercial e-mail.
|
||||||
|
|
||||||
## API users
|
## API users
|
||||||
|
|
||||||
| User | Can do | Token |
|
| User | Can do | Token |
|
||||||
|
|||||||
@@ -57,6 +57,16 @@ listmonk_oidc_admins:
|
|||||||
email: eric@eom.dev
|
email: eric@eom.dev
|
||||||
|
|
||||||
listmonk_site_name: eom.dev
|
listmonk_site_name: eom.dev
|
||||||
|
# E-mail design (templates/email/): Catppuccin Mocha like www.eom.dev.
|
||||||
|
# The logo must be a PNG; most mail clients don't render SVG.
|
||||||
|
listmonk_email_logo_url: https://www.eom.dev/static/email-logo.png
|
||||||
|
listmonk_email_home_url: https://www.eom.dev/
|
||||||
|
listmonk_email_members_url: https://www.eom.dev/members/
|
||||||
|
# Postal address in every footer (CAN-SPAM wants one for commercial mail).
|
||||||
|
listmonk_email_postal_address: ""
|
||||||
|
# Campaign template names; the first becomes the default for new campaigns.
|
||||||
|
listmonk_email_template_name: eom.dev
|
||||||
|
listmonk_email_archive_template_name: eom.dev archive
|
||||||
listmonk_logo_url: https://dex.eom.dev/theme/logo.svg
|
listmonk_logo_url: https://dex.eom.dev/theme/logo.svg
|
||||||
listmonk_favicon_url: https://www.eom.dev/static/favicon.svg
|
listmonk_favicon_url: https://www.eom.dev/static/favicon.svg
|
||||||
listmonk_notify_emails:
|
listmonk_notify_emails:
|
||||||
|
|||||||
@@ -43,6 +43,14 @@
|
|||||||
wait: true
|
wait: true
|
||||||
wait_timeout: "{{ listmonk_wait_timeout }}"
|
wait_timeout: "{{ listmonk_wait_timeout }}"
|
||||||
|
|
||||||
|
- name: Store the system e-mail templates
|
||||||
|
kubernetes.core.k8s:
|
||||||
|
kubeconfig: "{{ listmonk_kubeconfig | default(omit, true) }}"
|
||||||
|
context: "{{ listmonk_kube_context | default(omit, true) }}"
|
||||||
|
state: present
|
||||||
|
apply: true
|
||||||
|
definition: "{{ lookup('ansible.builtin.template', 'email-configmap.yml.j2') | from_yaml }}"
|
||||||
|
|
||||||
- name: Deploy listmonk
|
- name: Deploy listmonk
|
||||||
kubernetes.core.k8s:
|
kubernetes.core.k8s:
|
||||||
kubeconfig: "{{ listmonk_kubeconfig | default(omit, true) }}"
|
kubeconfig: "{{ listmonk_kubeconfig | default(omit, true) }}"
|
||||||
|
|||||||
@@ -168,6 +168,40 @@ WITH ur AS (
|
|||||||
SELECT count(*) FROM up;
|
SELECT count(*) FROM up;
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
{#- Campaign templates (templates/email/campaign.html.j2): created by name,
|
||||||
|
body kept in sync, and the main one is the default for new campaigns. #}
|
||||||
|
{%- set email_templates = [
|
||||||
|
(listmonk_email_template_name, lookup('ansible.builtin.template', 'email/campaign.html.j2', template_vars={'archive': false})),
|
||||||
|
(listmonk_email_archive_template_name, lookup('ansible.builtin.template', 'email/campaign.html.j2', template_vars={'archive': true})),
|
||||||
|
] %}
|
||||||
|
{% for name, body in email_templates %}
|
||||||
|
WITH ins AS (
|
||||||
|
INSERT INTO templates (name, type, subject, body)
|
||||||
|
SELECT {{ q(name) }}, 'campaign', '{{ '{{' }} .Campaign.Subject {{ '}}' }}', {{ q(body) }}
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM templates WHERE name = {{ q(name) }} AND type = 'campaign')
|
||||||
|
RETURNING id
|
||||||
|
)
|
||||||
|
SELECT count(*) FROM ins;
|
||||||
|
WITH upd AS (
|
||||||
|
UPDATE templates SET body = {{ q(body) }}, updated_at = NOW()
|
||||||
|
WHERE name = {{ q(name) }} AND type = 'campaign' AND body IS DISTINCT FROM {{ q(body) }}
|
||||||
|
RETURNING id
|
||||||
|
)
|
||||||
|
SELECT count(*) FROM upd;
|
||||||
|
{% endfor %}
|
||||||
|
WITH unset AS (
|
||||||
|
UPDATE templates SET is_default = false
|
||||||
|
WHERE is_default AND NOT (name = {{ q(listmonk_email_template_name) }} AND type = 'campaign')
|
||||||
|
RETURNING id
|
||||||
|
)
|
||||||
|
SELECT count(*) FROM unset;
|
||||||
|
WITH setd AS (
|
||||||
|
UPDATE templates SET is_default = true
|
||||||
|
WHERE id = (SELECT min(id) FROM templates WHERE name = {{ q(listmonk_email_template_name) }} AND type = 'campaign') AND NOT is_default
|
||||||
|
RETURNING id
|
||||||
|
)
|
||||||
|
SELECT count(*) FROM setd;
|
||||||
|
|
||||||
{% if listmonk_remove_sample_data | bool %}
|
{% if listmonk_remove_sample_data | bool %}
|
||||||
WITH del AS (
|
WITH del AS (
|
||||||
DELETE FROM campaigns
|
DELETE FROM campaigns
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: {{ listmonk_name }}-email-templates
|
||||||
|
namespace: {{ listmonk_namespace }}
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: listmonk
|
||||||
|
app.kubernetes.io/instance: {{ listmonk_name }}
|
||||||
|
data:
|
||||||
|
# Overrides only this file; every other system template stays built in.
|
||||||
|
base.html: {{ lookup('ansible.builtin.template', 'email/base.html.j2') | to_json }}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#jinja2:variable_start_string:'[[', variable_end_string:']]', block_start_string:'[%', block_end_string:'%]'
|
||||||
|
{{ define "header" }}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
|
||||||
|
<meta name="color-scheme" content="dark">
|
||||||
|
<meta name="supported-color-schemes" content="dark">
|
||||||
|
<base target="_blank">
|
||||||
|
<!-- eom.dev system e-mails (opt-in, password reset, ...). Managed by ansible-role-listmonk. -->
|
||||||
|
<style>
|
||||||
|
body { margin: 0; padding: 0; background: #181825; color: #cdd6f4; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.65; }
|
||||||
|
.wrap { max-width: 600px; margin: 0 auto; background: #1e1e2e; border: 1px solid #3c3d56; border-radius: 20px; padding: 24px 36px 20px; color: #cdd6f4; }
|
||||||
|
.header { border-bottom: 1px solid #3c3d56; padding-bottom: 20px; margin-bottom: 24px; }
|
||||||
|
.header img { display: block; border: 0; width: 240px; height: 72px; }
|
||||||
|
.gutter { padding: 16px; }
|
||||||
|
h1, h2, h3 { color: #cdd6f4; line-height: 1.3; }
|
||||||
|
h2 { font-size: 21px; font-weight: 750; margin: 0 0 12px; }
|
||||||
|
p { margin: 0 0 16px; }
|
||||||
|
a { color: #cba6f7; }
|
||||||
|
ul { padding-left: 22px; }
|
||||||
|
table td { color: #cdd6f4; border-bottom: 1px solid #3c3d56; padding: 6px 4px; }
|
||||||
|
.button { display: inline-block; background: #cba6f7; color: #11111b !important; text-decoration: none; font-weight: 700; padding: 12px 22px; border-radius: 10px; }
|
||||||
|
.footer { text-align: center; font-size: 13px; color: #7f849c; }
|
||||||
|
.footer a { color: #a6adc8; }
|
||||||
|
img { max-width: 100%; }
|
||||||
|
@media screen and (max-width: 620px) {
|
||||||
|
.wrap { border-radius: 0; border-left: 0; border-right: 0; padding: 20px; }
|
||||||
|
.gutter { padding: 6px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background:#181825;" bgcolor="#181825">
|
||||||
|
<div class="gutter"> </div>
|
||||||
|
<div class="wrap" style="background:#1e1e2e;border:1px solid #3c3d56;border-radius:20px;color:#cdd6f4;">
|
||||||
|
<div class="header">
|
||||||
|
<a href="[[ listmonk_email_home_url ]]"><img src="[[ listmonk_email_logo_url ]]" width="240" height="72" alt="eom.dev"></a>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "footer" }}
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p><a href="[[ listmonk_email_home_url ]]">eom.dev</a> · <a href="[[ listmonk_email_members_url ]]">Membership</a></p>
|
||||||
|
[% if listmonk_email_postal_address | length > 0 %]
|
||||||
|
<p>[[ listmonk_email_postal_address | e ]]</p>
|
||||||
|
[% endif %]
|
||||||
|
</div>
|
||||||
|
<div class="gutter"> </div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{ end }}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
#jinja2:variable_start_string:'[[', variable_end_string:']]', block_start_string:'[%', block_end_string:'%]'
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
|
||||||
|
<meta name="color-scheme" content="dark">
|
||||||
|
<meta name="supported-color-schemes" content="dark">
|
||||||
|
<base target="_blank">
|
||||||
|
<title>{{ .Campaign.Subject }}</title>
|
||||||
|
<!-- eom.dev newsletter: Catppuccin Mocha, as on www.eom.dev. Managed by ansible-role-listmonk. -->
|
||||||
|
<style>
|
||||||
|
body { margin: 0; padding: 0; background: #181825; -webkit-text-size-adjust: 100%; }
|
||||||
|
.content { color: #cdd6f4; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.65; }
|
||||||
|
.content h1 { font-size: 27px; line-height: 1.25; font-weight: 800; letter-spacing: -0.4px; margin: 0 0 16px; color: #cdd6f4; }
|
||||||
|
.content h2 { font-size: 21px; line-height: 1.3; font-weight: 750; margin: 28px 0 12px; color: #cdd6f4; }
|
||||||
|
.content h3 { font-size: 17px; line-height: 1.35; font-weight: 700; margin: 24px 0 8px; color: #b4befe; }
|
||||||
|
.content h1:first-child, .content h2:first-child { margin-top: 0; }
|
||||||
|
.content p { margin: 0 0 16px; }
|
||||||
|
.content a { color: #cba6f7; text-decoration: underline; }
|
||||||
|
.content strong { color: #cdd6f4; }
|
||||||
|
.content ul, .content ol { margin: 0 0 16px; padding-left: 22px; }
|
||||||
|
.content li { margin: 0 0 6px; }
|
||||||
|
.content blockquote { margin: 0 0 16px; padding: 2px 0 2px 16px; border-left: 3px solid #cba6f7; color: #a6adc8; }
|
||||||
|
.content code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 14px; background: #181825; border: 1px solid #3c3d56; border-radius: 5px; padding: 1px 5px; }
|
||||||
|
.content pre { margin: 0 0 16px; padding: 14px 16px; background: #181825; border: 1px solid #3c3d56; border-radius: 10px; overflow-x: auto; }
|
||||||
|
.content pre code { border: 0; padding: 0; background: none; }
|
||||||
|
.content img { max-width: 100%; height: auto; border-radius: 12px; }
|
||||||
|
.content hr { border: 0; border-top: 1px solid #3c3d56; margin: 28px 0; }
|
||||||
|
.content table { border-collapse: collapse; margin: 0 0 16px; }
|
||||||
|
.content th, .content td { border: 1px solid #3c3d56; padding: 6px 10px; }
|
||||||
|
a.button, .content a.button { display: inline-block; background: #cba6f7; color: #11111b !important; text-decoration: none; font-weight: 700; padding: 12px 22px; border-radius: 10px; }
|
||||||
|
.footer a { color: #a6adc8; text-decoration: underline; }
|
||||||
|
@media screen and (max-width: 620px) {
|
||||||
|
.outer { padding: 0 !important; }
|
||||||
|
.card { border-radius: 0 !important; border-left: 0 !important; border-right: 0 !important; }
|
||||||
|
.pad { padding-left: 20px !important; padding-right: 20px !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background:#181825;" bgcolor="#181825">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#181825" style="background:#181825;">
|
||||||
|
<tr>
|
||||||
|
<td class="outer" align="center" style="padding:32px 12px;">
|
||||||
|
<table role="presentation" class="card" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#1e1e2e" style="max-width:600px;background:#1e1e2e;border:1px solid #3c3d56;border-radius:20px;">
|
||||||
|
<tr>
|
||||||
|
<td class="pad" style="padding:24px 36px 20px;">
|
||||||
|
<a href="[[ listmonk_email_home_url ]]" style="text-decoration:none;"><img src="[[ listmonk_email_logo_url ]]" width="240" height="72" alt="eom.dev" style="display:block;border:0;outline:none;width:240px;height:72px;"></a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="pad" style="padding:0 36px;"><div style="height:1px;line-height:1px;font-size:1px;background:#3c3d56;"> </div></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="pad content" style="padding:28px 36px 16px;color:#cdd6f4;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;font-size:16px;line-height:1.65;">
|
||||||
|
{{ template "content" . }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;">
|
||||||
|
<tr>
|
||||||
|
<td class="footer pad" align="center" style="padding:20px 24px 8px;color:#7f849c;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;font-size:13px;line-height:1.6;">
|
||||||
|
<p style="margin:0 0 8px;">
|
||||||
|
<a href="[[ listmonk_email_home_url ]]" style="color:#a6adc8;">eom.dev</a> ·
|
||||||
|
<a href="[[ listmonk_email_members_url ]]" style="color:#a6adc8;">Membership</a>
|
||||||
|
[% if not archive %]
|
||||||
|
· <a href="{{ MessageURL }}" style="color:#a6adc8;">View in browser</a>
|
||||||
|
[% endif %]
|
||||||
|
</p>
|
||||||
|
[% if not archive %]
|
||||||
|
<p style="margin:0 0 8px;">You're receiving this because you subscribed to the eom.dev newsletter.<br>
|
||||||
|
<a href="{{ ManageURL }}" style="color:#a6adc8;">Manage preferences</a> ·
|
||||||
|
<a href="{{ UnsubscribeURL }}" style="color:#a6adc8;">Unsubscribe</a>
|
||||||
|
</p>
|
||||||
|
[% endif %]
|
||||||
|
[% if listmonk_email_postal_address | length > 0 %]
|
||||||
|
<p style="margin:0 0 8px;">[[ listmonk_email_postal_address | e ]]</p>
|
||||||
|
[% endif %]
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
[% if not archive %]
|
||||||
|
{{ TrackView }}
|
||||||
|
[% endif %]
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -39,6 +39,7 @@ spec:
|
|||||||
app.kubernetes.io/component: app
|
app.kubernetes.io/component: app
|
||||||
annotations:
|
annotations:
|
||||||
checksum/secret: {{ lookup('ansible.builtin.template', 'secret.yml.j2') | hash('sha256') }}
|
checksum/secret: {{ lookup('ansible.builtin.template', 'secret.yml.j2') | hash('sha256') }}
|
||||||
|
checksum/email: {{ lookup('ansible.builtin.template', 'email-configmap.yml.j2') | hash('sha256') }}
|
||||||
spec:
|
spec:
|
||||||
affinity: {{ listmonk_affinity | to_json }}
|
affinity: {{ listmonk_affinity | to_json }}
|
||||||
containers:
|
containers:
|
||||||
@@ -47,14 +48,15 @@ spec:
|
|||||||
imagePullPolicy: {{ listmonk_image_pull_policy }}
|
imagePullPolicy: {{ listmonk_image_pull_policy }}
|
||||||
# --config '' : configuration comes from the environment only.
|
# --config '' : configuration comes from the environment only.
|
||||||
# --install --idempotent only installs into an empty database;
|
# --install --idempotent only installs into an empty database;
|
||||||
# --upgrade runs migrations after an image bump.
|
# --upgrade runs migrations after an image bump. --static-dir
|
||||||
|
# overlays the eom.dev system e-mail header/footer (base.html).
|
||||||
command:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -c
|
||||||
- >-
|
- >-
|
||||||
./listmonk --install --idempotent --yes --config '' &&
|
./listmonk --install --idempotent --yes --config '' &&
|
||||||
./listmonk --upgrade --yes --config '' &&
|
./listmonk --upgrade --yes --config '' &&
|
||||||
exec ./listmonk --config ''
|
exec ./listmonk --config '' --static-dir /listmonk/static-eom
|
||||||
env:
|
env:
|
||||||
- name: TZ
|
- name: TZ
|
||||||
value: America/New_York
|
value: America/New_York
|
||||||
@@ -97,7 +99,13 @@ spec:
|
|||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: uploads
|
- name: uploads
|
||||||
mountPath: /listmonk/uploads
|
mountPath: /listmonk/uploads
|
||||||
|
- name: email-templates
|
||||||
|
mountPath: /listmonk/static-eom/email-templates
|
||||||
|
readOnly: true
|
||||||
volumes:
|
volumes:
|
||||||
|
- name: email-templates
|
||||||
|
configMap:
|
||||||
|
name: {{ listmonk_name }}-email-templates
|
||||||
- name: uploads
|
- name: uploads
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: {{ listmonk_name }}-uploads
|
claimName: {{ listmonk_name }}-uploads
|
||||||
|
|||||||
+2
-2
@@ -29,9 +29,9 @@
|
|||||||
src: "{{ role_path }}/templates/{{ item }}.j2"
|
src: "{{ role_path }}/templates/{{ item }}.j2"
|
||||||
dest: "{{ out }}/{{ item }}"
|
dest: "{{ out }}/{{ item }}"
|
||||||
mode: "0600"
|
mode: "0600"
|
||||||
loop: [secret.yml, postgres.yml, listmonk.yml, configure.sql]
|
loop: [secret.yml, postgres.yml, listmonk.yml, configure.sql, email-configmap.yml]
|
||||||
- name: Manifests parse as YAML
|
- name: Manifests parse as YAML
|
||||||
ansible.builtin.assert:
|
ansible.builtin.assert:
|
||||||
that: lookup('ansible.builtin.file', out ~ '/' ~ item) | from_yaml_all | list | length > 0
|
that: lookup('ansible.builtin.file', out ~ '/' ~ item) | from_yaml_all | list | length > 0
|
||||||
quiet: true
|
quiet: true
|
||||||
loop: [secret.yml, postgres.yml, listmonk.yml]
|
loop: [secret.yml, postgres.yml, listmonk.yml, email-configmap.yml]
|
||||||
|
|||||||
Reference in New Issue
Block a user