Compare commits

...
4 Commits
Author SHA1 Message Date
claudeandClaude Opus 5.5 a58a99bf3b Mount base.html by subPath; ConfigMap ..data dirs crash listmonk
A whole-directory ConfigMap mount puts ..data symlink dirs in
--static-dir, which listmonk refuses to read (CrashLoopBackOff).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 21:52:21 -05:00
claudeandClaude Opus 5.5 a74d580b0a 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>
2026-11-06 21:36:39 -05:00
claudeandClaude Opus 5.5 baa2d4b521 Send the admin login straight to Dex
A public custom script submits the OIDC form on /admin/login and CSS
hides the password form. /admin/login?local=1 (or a failed password
attempt) shows it for break-glass use; a 15s guard prevents loops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 21:05:40 -05:00
claudeandClaude Opus 5.5 e4a3d13870 Add a scoped newsletter API user for the scheduled campaign task
Token is generated once and kept in Secret listmonk-newsletter-api.
Tested: create, test-send and schedule work; subscribers/settings 403.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 20:32:03 -05:00
11 changed files with 346 additions and 18 deletions
+19 -1
View File
@@ -4,7 +4,7 @@ Deploys [listmonk](https://listmonk.app) v6.2.0, the eom.dev newsletter, to Kube
It fits in with the rest of eom.dev: It fits in with the rest of eom.dev:
- **Login** goes through Dex (`listmonk` static client) with the same "eom.dev account" button as the other services. Only e-mail addresses listed in `listmonk_oidc_admins` can sign in, so being a member is not enough to get into the admin UI. A password login for the `admin` user is kept as a fallback. - **Login** goes through Dex (`listmonk` static client) with the same "eom.dev account" button as the other services. Only e-mail addresses listed in `listmonk_oidc_admins` can sign in, so being a member is not enough to get into the admin UI. The admin login page sends you straight to Dex. For the `admin` password login, kept as a fallback for when Dex is down, use `/admin/login?local=1`.
- **Mail** goes out through `postfix.eom.dev:587` as the LDAP account `news`, the same way Gitea, Nextcloud and PeerTube send mail. - **Mail** goes out through `postfix.eom.dev:587` as the LDAP account `news`, the same way Gitea, Nextcloud and PeerTube send mail.
- **Look**: the public pages (subscribe, archive, preferences, unsubscribe) use the Catppuccin Mocha/Latte palette from `dex.eom.dev`, along with the eom.dev logo and favicon. - **Look**: the public pages (subscribe, archive, preferences, unsubscribe) use the Catppuccin Mocha/Latte palette from `dex.eom.dev`, along with the eom.dev logo and favicon.
- **Cluster conventions**: `ca-issuer` TLS, `nfs-client` volumes, and anti-affinity that keeps the pods off the ingress-nginx node. - **Cluster conventions**: `ca-issuer` TLS, `nfs-client` volumes, and anti-affinity that keeps the pods off the ingress-nginx node.
@@ -39,6 +39,24 @@ 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
| User | Can do | Token |
| --- | --- | --- |
| `membership` | Read and manage subscribers on `eom.dev` (signup opt-in, profile toggle) | `listmonk_api_token` (Vault); same value as `LISTMONK_API_TOKEN` in the www `membership` Secret |
| `newsletter` | Create, edit, test-send and schedule campaigns to `eom.dev`; no subscriber data or settings | Generated by the role, stored in `Secret/listmonk-newsletter-api` (`url`, `username`, `token`) |
Neither user can read settings or other lists. To rotate the newsletter token, delete its Secret and run the play again.
## Embedding a signup form ## Embedding a signup form
listmonk accepts a plain HTML POST, so any eom.dev page can include a form: listmonk accepts a plain HTML POST, so any eom.dev page can include a form:
+20
View File
@@ -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:
@@ -81,6 +91,16 @@ listmonk_api_role: Membership sync
listmonk_api_lists: listmonk_api_lists:
- eom.dev - eom.dev
# API user for the scheduled newsletter task: create, edit and schedule
# campaigns to listmonk_newsletter_api_lists; no subscriber data or settings.
# Its token is generated once and kept in Secret <name>-newsletter-api
# (keys: url, username, token) for the task to read with kubectl.
listmonk_newsletter_api_enabled: true
listmonk_newsletter_api_user: newsletter
listmonk_newsletter_api_role: Newsletter automation
listmonk_newsletter_api_lists:
- eom.dev
# Drop the sample lists, subscribers and campaign created by --install. # Drop the sample lists, subscribers and campaign created by --install.
listmonk_remove_sample_data: true listmonk_remove_sample_data: true
+3
View File
@@ -71,3 +71,6 @@ input::placeholder { color: var(--muted); }
.button.button-outline:hover { background: var(--surface); color: var(--accent-hover); } .button.button-outline:hover { background: var(--surface); color: var(--accent-hover); }
.error { color: var(--error); } .error { color: var(--error); }
.pg-page.pg-selected { background: var(--accent); color: var(--on-accent); } .pg-page.pg-selected { background: var(--accent); color: var(--on-accent); }
/* Admin login is Dex only; ?local=1 (public.js) reveals the password form. */
.login form[action="/admin/login"], .login .center.small { display: none; }
.eom-local-login .login form[action="/admin/login"], .eom-local-login .login .center.small { display: block; }
+20
View File
@@ -0,0 +1,20 @@
// eom.dev: the admin login goes straight to Dex.
// Break-glass password login: /admin/login?local=1
(function () {
if (window.location.pathname !== "/admin/login") return;
var local = new URLSearchParams(window.location.search).has("local");
var go = function () {
var root = document.documentElement;
// A failed password attempt re-renders /admin/login without ?local=1.
if (local || document.querySelector(".login .error")) { root.classList.add("eom-local-login"); return; }
var form = document.querySelector('form[action="/auth/oidc"]');
if (!form) return;
// Don't loop if Dex sent us straight back (e.g. login refused).
var key = "eom-oidc-redirect", now = Date.now(), last = 0;
try { last = Number(sessionStorage.getItem(key)) || 0; sessionStorage.setItem(key, String(now)); } catch (e) {}
if (now - last < 15000) return;
form.submit();
};
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", go);
else go();
})();
+48
View File
@@ -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) }}"
@@ -53,6 +61,46 @@
wait: true wait: true
wait_timeout: "{{ listmonk_wait_timeout }}" wait_timeout: "{{ listmonk_wait_timeout }}"
- name: Read the newsletter automation credentials
kubernetes.core.k8s_info:
kubeconfig: "{{ listmonk_kubeconfig | default(omit, true) }}"
context: "{{ listmonk_kube_context | default(omit, true) }}"
kind: Secret
namespace: "{{ listmonk_namespace }}"
name: "{{ listmonk_name }}-newsletter-api"
register: _listmonk_newsletter_secret
no_log: true
when: listmonk_newsletter_api_enabled | bool
# Generated once, then reused; delete the Secret to rotate the token.
- name: Use or generate the newsletter automation token
ansible.builtin.set_fact:
listmonk_newsletter_api_token: >-
{{ (_listmonk_newsletter_secret.resources[0].data.token | b64decode)
if _listmonk_newsletter_secret.resources | length > 0
else lookup('ansible.builtin.password', '/dev/null', chars=['ascii_letters', 'digits'], length=48) }}
no_log: true
when: listmonk_newsletter_api_enabled | bool
- name: Store the newsletter automation credentials
kubernetes.core.k8s:
kubeconfig: "{{ listmonk_kubeconfig | default(omit, true) }}"
context: "{{ listmonk_kube_context | default(omit, true) }}"
state: present
definition:
apiVersion: v1
kind: Secret
metadata:
name: "{{ listmonk_name }}-newsletter-api"
namespace: "{{ listmonk_namespace }}"
type: Opaque
stringData:
url: "{{ listmonk_root_url }}"
username: "{{ listmonk_newsletter_api_user }}"
token: "{{ listmonk_newsletter_api_token }}"
no_log: true
when: listmonk_newsletter_api_enabled | bool
# Settings, Dex admins and lists live in listmonk's database, not its config. # Settings, Dex admins and lists live in listmonk's database, not its config.
- name: Converge database-held configuration - name: Converge database-held configuration
kubernetes.core.k8s_exec: kubernetes.core.k8s_exec:
+66 -13
View File
@@ -52,6 +52,7 @@
}], }],
'appearance.public.custom_css': lookup('ansible.builtin.file', role_path ~ '/files/public.css'), 'appearance.public.custom_css': lookup('ansible.builtin.file', role_path ~ '/files/public.css'),
'appearance.admin.custom_css': lookup('ansible.builtin.file', role_path ~ '/files/admin.css'), 'appearance.admin.custom_css': lookup('ansible.builtin.file', role_path ~ '/files/admin.css'),
'appearance.public.custom_js': lookup('ansible.builtin.file', role_path ~ '/files/public.js'),
} | combine(listmonk_settings_extra) -%} } | combine(listmonk_settings_extra) -%}
\set ON_ERROR_STOP on \set ON_ERROR_STOP on
BEGIN; BEGIN;
@@ -103,41 +104,59 @@ WITH upd AS (
SELECT count(*) FROM upd; SELECT count(*) FROM upd;
{% endfor %} {% endfor %}
{% if listmonk_api_token | length > 0 %} {#- Scoped API users: the membership app (subscribers) and the newsletter
{#- API user for the membership app: subscribers on listmonk_api_lists only. #} automation (campaigns). Each gets a user role and a list role limited to
its lists. listmonk caches API users at startup; the play restarts it. #}
{%- set api_users = [] %}
{%- if listmonk_api_token | length > 0 %}
{%- set _ = api_users.append({'username': listmonk_api_user, 'token': listmonk_api_token, 'role': listmonk_api_role,
'permissions': ['subscribers:get', 'subscribers:manage'], 'lists': listmonk_api_lists}) %}
{%- endif %}
{%- if listmonk_newsletter_api_token | default('') | length > 0 %}
{%- set _ = api_users.append({'username': listmonk_newsletter_api_user, 'token': listmonk_newsletter_api_token, 'role': listmonk_newsletter_api_role,
'permissions': ['campaigns:get', 'campaigns:manage', 'campaigns:send', 'templates:get'], 'lists': listmonk_newsletter_api_lists}) %}
{%- endif %}
{% for api in api_users %}
WITH ins AS ( WITH ins AS (
INSERT INTO roles (type, name, permissions) INSERT INTO roles (type, name, permissions)
SELECT 'user', {{ q(listmonk_api_role) }}, ARRAY['subscribers:get', 'subscribers:manage'] SELECT 'user', {{ q(api.role) }}, ARRAY[{% for p in api.permissions %}{{ q(p) }}{{ ',' if not loop.last }}{% endfor %}]
WHERE NOT EXISTS (SELECT 1 FROM roles WHERE type = 'user' AND name = {{ q(listmonk_api_role) }}) WHERE NOT EXISTS (SELECT 1 FROM roles WHERE type = 'user' AND name = {{ q(api.role) }})
RETURNING id RETURNING id
) )
SELECT count(*) FROM ins; SELECT count(*) FROM ins;
WITH upd AS (
UPDATE roles SET permissions = ARRAY[{% for p in api.permissions %}{{ q(p) }}{{ ',' if not loop.last }}{% endfor %}], updated_at = NOW()
WHERE type = 'user' AND name = {{ q(api.role) }}
AND permissions IS DISTINCT FROM ARRAY[{% for p in api.permissions %}{{ q(p) }}{{ ',' if not loop.last }}{% endfor %}]
RETURNING id
)
SELECT count(*) FROM upd;
WITH ins AS ( WITH ins AS (
INSERT INTO roles (type, name) INSERT INTO roles (type, name)
SELECT 'list', {{ q(listmonk_api_role) }} SELECT 'list', {{ q(api.role) }}
WHERE NOT EXISTS (SELECT 1 FROM roles WHERE type = 'list' AND name = {{ q(listmonk_api_role) }} AND parent_id IS NULL) WHERE NOT EXISTS (SELECT 1 FROM roles WHERE type = 'list' AND name = {{ q(api.role) }} AND parent_id IS NULL)
RETURNING id RETURNING id
) )
SELECT count(*) FROM ins; SELECT count(*) FROM ins;
WITH parent AS ( WITH parent AS (
SELECT id FROM roles WHERE type = 'list' AND name = {{ q(listmonk_api_role) }} AND parent_id IS NULL SELECT id FROM roles WHERE type = 'list' AND name = {{ q(api.role) }} AND parent_id IS NULL
), ins AS ( ), ins AS (
INSERT INTO roles (type, parent_id, list_id, permissions) INSERT INTO roles (type, parent_id, list_id, permissions)
SELECT 'list', parent.id, l.id, ARRAY['list:get', 'list:manage'] SELECT 'list', parent.id, l.id, ARRAY['list:get', 'list:manage']
FROM parent, lists l FROM parent, lists l
WHERE l.name IN ({% for n in listmonk_api_lists %}{{ q(n) }}{{ ',' if not loop.last }}{% endfor %}) WHERE l.name IN ({% for n in api.lists %}{{ q(n) }}{{ ',' if not loop.last }}{% endfor %})
ON CONFLICT (parent_id, list_id) DO NOTHING ON CONFLICT (parent_id, list_id) DO NOTHING
RETURNING id RETURNING id
) )
SELECT count(*) FROM ins; SELECT count(*) FROM ins;
WITH ur AS ( WITH ur AS (
SELECT id FROM roles WHERE type = 'user' AND name = {{ q(listmonk_api_role) }} SELECT id FROM roles WHERE type = 'user' AND name = {{ q(api.role) }}
), lr AS ( ), lr AS (
SELECT id FROM roles WHERE type = 'list' AND name = {{ q(listmonk_api_role) }} AND parent_id IS NULL SELECT id FROM roles WHERE type = 'list' AND name = {{ q(api.role) }} AND parent_id IS NULL
), up AS ( ), up AS (
INSERT INTO users (username, password_login, password, email, name, type, user_role_id, list_role_id, status) INSERT INTO users (username, password_login, password, email, name, type, user_role_id, list_role_id, status)
SELECT {{ q(listmonk_api_user) }}, false, {{ q(listmonk_api_token | hash('sha256')) }}, SELECT {{ q(api.username) }}, false, {{ q(api.token | hash('sha256')) }},
{{ q(listmonk_api_user ~ '@api') }}, {{ q(listmonk_api_user) }}, 'api', ur.id, lr.id, 'enabled' {{ q(api.username ~ '@api') }}, {{ q(api.username) }}, 'api', ur.id, lr.id, 'enabled'
FROM ur, lr FROM ur, lr
ON CONFLICT (username) DO UPDATE ON CONFLICT (username) DO UPDATE
SET password = EXCLUDED.password, user_role_id = EXCLUDED.user_role_id, SET password = EXCLUDED.password, user_role_id = EXCLUDED.user_role_id,
@@ -147,7 +166,41 @@ WITH ur AS (
RETURNING id RETURNING id
) )
SELECT count(*) FROM up; SELECT count(*) FROM up;
{% endif %} {% 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 (
+11
View File
@@ -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 }}
+53
View File
@@ -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">&nbsp;</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> &nbsp;·&nbsp; <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">&nbsp;</div>
</body>
</html>
{{ end }}
+89
View File
@@ -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;">&nbsp;</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> &nbsp;·&nbsp;
<a href="[[ listmonk_email_members_url ]]" style="color:#a6adc8;">Membership</a>
[% if not archive %]
&nbsp;·&nbsp; <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> &nbsp;·&nbsp;
<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>
+14 -2
View File
@@ -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,17 @@ spec:
volumeMounts: volumeMounts:
- name: uploads - name: uploads
mountPath: /listmonk/uploads mountPath: /listmonk/uploads
# Mount the single file: a ConfigMap directory holds ..data
# symlink dirs that listmonk's static loader refuses to read.
# (Not live-updated; checksum/email rolls the pod instead.)
- name: email-templates
mountPath: /listmonk/static-eom/email-templates/base.html
subPath: base.html
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
+3 -2
View File
@@ -18,6 +18,7 @@
listmonk_smtp_password: render-only listmonk_smtp_password: render-only
listmonk_oidc_client_secret: render-only listmonk_oidc_client_secret: render-only
listmonk_api_token: render-only-api-token listmonk_api_token: render-only-api-token
listmonk_newsletter_api_token: render-only-newsletter-token
- name: Output directory - name: Output directory
ansible.builtin.file: ansible.builtin.file:
path: "{{ out }}" path: "{{ out }}"
@@ -28,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]