Rework email template

This commit is contained in:
mplorentz 2026-08-26 17:57:26 -04:00
parent 77e35c26c7
commit 6e35717072
9 changed files with 202 additions and 42 deletions

View file

@ -1,7 +1,14 @@
MAILSHIP_SECRET=
MAILSHIP_NAME=Mailship
MAILSHIP_NAME=Flotilla
MAILSHIP_URL=http://localhost:4738
BASE_URL=http://localhost:4738
# Branding used in email templates. EVENT_VIEWER_URL is the base URL of the
# app you link events into (Flotilla by default, or anything handling the same
# /spaces/<relay>/<group> and /<nevent> URL shapes).
EVENT_VIEWER_URL=https://app.flotilla.social
BRAND_NAME=Flotilla
BRAND_ACCENT=#7161FF
BRAND_LOGO=
INDEXER_RELAYS=purplepag.es,relay.damus.io,relay.nostr.band
DEFAULT_RELAYS=relay.damus.io,nos.lol
SEARCH_RELAYS=relay.nostr.band

View file

@ -23,6 +23,17 @@ bd close <id> # Complete work
bd dolt push # Push beads data to remote
```
## Email Template Preview
To preview the rendered digest email while iterating on `src/emails/digest.mjml`:
```bash
pnpm run preview:digest # renders to digest-preview.html
open digest-preview.html
```
After changing the template, rerun the script (or it doesn't watch) and refresh the browser.
## Non-Interactive Shell Commands
**ALWAYS use non-interactive flags** with file operations to avoid hanging on confirmation prompts.

View file

@ -30,6 +30,10 @@ Flotilla ──HTTP──▶ Mailship (POST /subscription/email)
| `MAILSHIP_NAME` | ✓ | Name of this Mailship instance |
| `MAILSHIP_URL` | ✓ | Public URL of this instance |
| `BASE_URL` | ✓ | Base URL for callback URLs (same as MAILSHIP_URL typically) |
| `EVENT_VIEWER_URL` | | Base URL of the app event links open in (defaults to Flotilla at `https://app.flotilla.social`, or anything handling the same `/spaces/<relay>/<hash>` and `/<nevent>` URL shapes) |
| `BRAND_NAME` | | Name used in email branding (default: `Flotilla`) |
| `BRAND_ACCENT` | | Accent color string used in email branding (default: `#7161FF`) |
| `BRAND_LOGO` | | URL of the logo image shown in the email header. Defaults to `<EVENT_VIEWER_URL>/logo.png`; if empty/unset, a colored brand name is shown instead |
| `POSTMARK_API_KEY` | ✓ | Postmark API key for sending emails |
| `POSTMARK_SENDER_ADDRESS` | ✓ | Verified sender email in Postmark |
| `DEFAULT_RELAYS` | ✓ | Comma-separated list of default relays |
@ -79,6 +83,16 @@ pnpm run build
pnpm run start
```
### Previewing the digest email
To iterate on the email template, render it with sample data and open the
result in your browser:
```sh
pnpm run preview:digest
open digest-preview.html
```
## Docker
### Quick start

View file

@ -9,6 +9,7 @@
"check": "tsc --noEmit && eslint src",
"format": "prettier --write \"src/**/*.{ts,js,json,html}\"",
"start": "node dist/index.js",
"preview:digest": "node script/render-preview.mjs",
"run-alert": "node dist/run.js",
"test": "bash test/integration.sh",
"test:server": "bash test/integration.sh --server-only"

49
script/render-preview.mjs Normal file
View file

@ -0,0 +1,49 @@
import {readFileSync, writeFileSync} from 'fs'
import {fileURLToPath} from 'url'
import {dirname, join} from 'path'
import Mustache from 'mustache'
import mjml2html from 'mjml'
const __dirname = dirname(fileURLToPath(import.meta.url))
const templatePath = join(__dirname, '..', 'src', 'emails', 'digest.mjml')
const sample = {
name: 'alice',
brandName: 'Flotilla',
brandAccent: '#7161FF',
brandLogo: 'https://app.flotilla.social/logo.png',
UserName: 'Alice',
Duration: '24 hours',
Total: 12,
TopProfiles: 'bob, carol',
HasPopular: true,
Popular: [
{
Link: 'https://app.flotilla.social/nevent1qqs...',
Timestamp: 'Aug 25, 2026 at 9:00 AM',
Icon: 'https://i.pravatar.cc/150?img=32',
Name: 'carol',
Content: '<p>Does anyone know how relay-based groups work?</p>',
Replies: 5,
Reactions: 8,
},
{
Link: 'https://app.flotilla.social/spaces/nos.lol/community?at=1700000000',
Timestamp: 'Aug 25, 2026 at 10:00 AM',
Icon: 'https://i.pravatar.cc/150?img=68',
Name: 'bob',
Content:
'<p>Excited to share our new community space on Flotilla! <a href="https://flotilla.social">Check it out</a>.</p>',
Replies: 14,
Reactions: 32,
},
],
unsubscribeUrl: 'https://app.flotilla.social/unsubscribe?token=abc123',
}
const source = readFileSync(templatePath, 'utf8')
const {html} = mjml2html(Mustache.render(source, sample))
const outPath = join(__dirname, '..', 'digest-preview.html')
writeFileSync(outPath, html)
console.log(`Rendered preview written to ${outPath}`)

View file

@ -13,6 +13,7 @@ import {
import { parse, truncate, renderAsHtml } from '@welshman/content'
import {
TrustedEvent,
normalizeRelayUrl,
getParentId,
getIdFilters,
getReplyFilters,
@ -21,6 +22,7 @@ import {
REACTION,
displayProfile,
displayPubkey,
getTagValue,
} from '@welshman/util'
import { Loader, AdapterContext, makeLoader, SocketAdapter } from '@welshman/net'
import { Router, addMinimalFallbacks } from '@welshman/router'
@ -28,6 +30,7 @@ import { call } from '@welshman/lib'
import { displayDuration, createElement } from './util.js'
import type { Subscription } from './alert.js'
import { sendDigest } from './mailer.js'
import { EVENT_VIEWER_URL } from './env.js'
import {
profilesByPubkey,
loadRelaySelections,
@ -48,7 +51,7 @@ export class Digest {
loadHandler = async () => {
// Default handler for building event links
return 'https://coracle.social/'
return `${EVENT_VIEWER_URL}/`
}
buildParameters = async (data: DigestData) => {
@ -73,7 +76,8 @@ export class Digest {
const handler = await this.loadHandler()
const repliesByParentId = groupBy(getParentId, context)
const eventsByPubkey = groupBy((e) => e.pubkey, events)
const popular = sortBy((e) => -(repliesByParentId.get(e.id)?.length || 0), events).slice(0, 12)
const userProfile = profilesByPubkey.get().get(this.sub.pubkey)
const sorted = sortBy((e) => e.created_at, events).slice(0, 100)
const topProfiles = sortBy(
([k, ev]) => -ev.length,
Array.from(eventsByPubkey.entries()).filter(([k]) => profilesByPubkey.get().get(k))
@ -82,8 +86,9 @@ export class Digest {
return {
Total: events.length,
Duration: displayDuration(Math.floor(Date.now() / 1000) - this.since),
Popular: popular.map((e) => getEventVariables(e)),
HasPopular: popular.length > 0,
Popular: sorted.map((e) => getEventVariables(e)),
HasPopular: sorted.length > 0,
UserName: displayProfile(userProfile, this.sub.email.split('@')[0]),
TopProfiles: displayList(topProfiles.map(([pk]) => displayProfileByPubkey(pk))),
}
}
@ -94,6 +99,7 @@ export class Digest {
// Load profiles for event authors
const pubkeys = new Set(events.map(e => e.pubkey))
pubkeys.add(this.sub.pubkey)
for (const pk of pubkeys) {
try {
await loadProfile(pk)
@ -114,6 +120,14 @@ export class Digest {
const buildLink = (event: TrustedEvent, handler: string) => {
const relays = Router.get().Event(event).getUrls()
const groupId = getTagValue(["h"], event.tags)
if (groupId && relays.length > 0) {
const relay = encodeRelay(relays[0])
const nevent = neventEncode({ id: event.id, relays })
return `${handler}spaces/${relay}/${encodeURIComponent(groupId)}?at=${event.created_at}#${nevent}`
}
const nevent = neventEncode({ ...event, relays })
if (handler.includes('<bech32>')) {
@ -123,6 +137,13 @@ const buildLink = (event: TrustedEvent, handler: string) => {
}
}
const encodeRelay = (url: string) =>
encodeURIComponent(
normalizeRelayUrl(url)
.replace(/^wss:\/\//, '')
.replace(/\/$/, ''),
)
const displayProfileByPubkey = (pubkey: string) =>
displayProfile(profilesByPubkey.get().get(pubkey), displayPubkey(pubkey))

View file

@ -1,36 +1,52 @@
<mjml>
<mj-head>
<mj-title>New Activity</mj-title>
<mj-title>New activity on Flotilla</mj-title>
<mj-font name="Inter" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700" />
<mj-style inline="inline">
.header { font-size: 24px; font-weight: bold; }
.subheader { font-size: 16px; color: #555; }
.event-item { margin-bottom: 20px; border-left: 3px solid #F45E43; padding-left: 10px; }
.header { font-family: Inter, Helvetica, Arial, sans-serif; font-size: 24px; font-weight: 700; }
.subheader { font-family: Inter, Helvetica, Arial, sans-serif; font-size: 15px; color: #64748b; line-height: 1.5; }
.event-item { margin-bottom: 20px; border-left: 3px solid #7161FF; padding-left: 12px; }
.event-meta { margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; }
.event-meta-left { display: flex; align-items: center; }
.event-author { font-weight: bold; margin-right: 4px; }
.event-content { white-space: pre-wrap; }
.event-timestamp { color: #777; font-size: 12px; }
.event-link { font-size: 12px; }
.section-header { font-size: 18px; font-weight: bold; margin-top: 15px; }
.event-author { font-family: Inter, Helvetica, Arial, sans-serif; font-weight: 600; margin-right: 4px; color: #1e293b; }
.event-content { font-family: Inter, Helvetica, Arial, sans-serif; white-space: pre-wrap; color: #334155; font-size: 14px; }
.event-timestamp { color: #94a3b8; font-size: 12px; }
.event-link { font-family: Inter, Helvetica, Arial, sans-serif; font-size: 13px; font-weight: 600; }
.event-link a { color: {{brandAccent}}; text-decoration: none; }
.section-header { font-family: Inter, Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 700; color: #1e293b; margin-top: 15px; }
.profile-image { width: 20px; height: 20px; border-radius: 50%; margin-right: 3px; vertical-align: middle; }
.event-stats { margin-top: 8px; color: #666; font-size: 14px; }
.user-photo { width: 32px; height: 32px; border-radius: 50%; margin-right: 6px; vertical-align: middle; }
.event-stats { margin-top: 8px; color: #64748b; font-size: 13px; }
.stat-item { display: inline-flex; align-items: center; margin-right: 12px; }
.stat-icon { width: 16px; height: 16px; margin-right: 4px; vertical-align: middle; }
.footer { font-family: Inter, Helvetica, Arial, sans-serif; color: #94a3b8; font-size: 12px; line-height: 1.5; }
.logo { max-width: 48px; max-height: 48px; }
a { text-decoration: none; }
</mj-style>
</mj-head>
<mj-body>
<mj-section background-color="#f0f0f0" padding="20px">
<mj-body background-color="#f0f2f5">
<mj-section background-color="#ffffff" padding="32px 32px 8px 32px">
<mj-column>
<mj-text css-class="header">Hello {{name}},</mj-text>
<mj-text css-class="subheader">
Below is a summary of activity over the last {{Duration}}.
We found {{Total}} new posts from {{TopProfiles}}.
{{#brandLogo}}
<mj-image src="{{brandLogo}}" alt="{{brandName}}" align="center" width="40px" padding="0 0 8px 0" />
{{/brandLogo}}
<mj-text css-class="header" align="center">
<span style="color: {{brandAccent}};">{{brandName}}</span>
</mj-text>
<mj-divider border-color="#cccccc" />
</mj-column>
</mj-section>
<mj-section background-color="#ffffff" padding="8px 32px 24px 32px">
<mj-column>
<mj-text css-class="subheader" align="center">
{{UserName}}, here's what's happening in your communities.
</mj-text>
<mj-divider border-color="#e2e8f0" />
</mj-column>
</mj-section>
{{#HasPopular}}
<mj-text css-class="section-header">Most Popular</mj-text>
{{#HasPopular}}
<mj-section background-color="#ffffff" padding="0 32px 24px 32px">
<mj-column>
<mj-text css-class="section-header">Latest Activity</mj-text>
{{#Popular}}
<mj-text>
<div class="event-item">
@ -41,32 +57,33 @@
<span class="event-timestamp">at {{Timestamp}}</span>
</div>
<div class="event-link">
<a href="{{Link}}">View post</a>
<a href="{{Link}}">View in {{brandName}}</a>
</div>
</div>
<div class="event-content">{{{Content}}}</div>
<div class="event-stats">
<span class="stat-item">
<img class="stat-icon" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAASFBMVEUAAAAiIkQdJ04bJkwbKU0dJkscKEwbJ00cKEsdJkwcJ0wbKEscJkwcJ0scJ00bJ00dJk0bJ0wcJkwcKE0cJ0wcJ0wcJ0z///8ZxKH0AAAAF3RSTlMADxovOD1AQlJXW19ld4iWoKizwenq9eQPMKMAAAABYktHRBcL1piPAAAAOElEQVQY02NgoBZg40fls4qw4+WziAnxggA3I1xAmA8EBEU5YEaKsoFpZlEmNBEBHjRruTip4wsADUgCEGuuAxQAAAAASUVORK5CYII=" />
{{Replies}}
{{Replies}} replies
</span>
<span class="stat-item">
<img class="stat-icon" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAn1BMVEUAAAAAAIAAAFUzMzMrK1UkJEkVK1UkJEkiIkQgIFAeLUscK0cbKFEbJEkaI08aK00ZKUocJUobKU0bKEwaKEsaJ04bJ0sbJ0sdJ0wcJ00cJk0cJkwcJk0bKEwbKEscKEwcJ00cJ0wcJ0wcJ0sbJk0bJ0wcJ0wcJ0sbJ0wdJ0wcJ00cKE0cJ0wbJ0wbJ0scJ0wcJ0wcJ0wcJ0wcJ0z///++FK4PAAAANHRSTlMAAgMFBgcMDg8QERITHB0eHzc4OTo7QVVhY2RleHl6h4iJiouMnq+wsbK+wcLD3+Dh4vHyB17REwAAAAFiS0dENKmx6f0AAACTSURBVBgZBcEJQoJAAADAIUhQkjxKJZdKyzPLXfj/35oBPDZNAYCXy5BSf14CHr7um5KyjfsMfP9MgPq2h9f7BKBOC1w2ALydKIaSLtAFqj43+0MIhID4bBYBIDWKoQJg3OecW9aH1eqwZnvEMtZG79drN1KnOexuNfD0+wGyXWwrxtv0mQEWpz6l/jgHQD6d5sA/VqYMPJkJl3UAAAAASUVORK5CYII=" />
{{Reactions}}
{{Reactions}} reactions
</span>
</div>
</div>
</mj-text>
{{/Popular}}
<mj-divider border-color="#cccccc" />
{{/HasPopular}}
</mj-column>
</mj-section>
{{/HasPopular}}
<mj-text>
You're receiving this email because you subscribed to notifications.
We'll continue to send you updates based on your subscription preferences.
<mj-section background-color="#ffffff" padding="0 32px 24px 32px">
<mj-column>
<mj-divider border-color="#e2e8f0" />
<mj-text css-class="footer">
You're receiving this email because you subscribed to {{brandName}} notifications.
We'll send you updates based on your subscription preferences.
</mj-text>
<mj-button background-color="#F45E43" color="#ffffff" href="{{unsubscribeUrl}}">
<mj-button background-color="{{brandAccent}}" color="#ffffff" href="{{unsubscribeUrl}}" padding-top="16px">
Unsubscribe
</mj-button>
</mj-column>

View file

@ -22,6 +22,11 @@ if (!process.env.BASE_URL) throw new Error('BASE_URL is not defined.')
export const MAILSHIP_URL = process.env.MAILSHIP_URL
export const MAILSHIP_NAME = process.env.MAILSHIP_NAME
export const BASE_URL = process.env.BASE_URL
export const EVENT_VIEWER_URL = process.env.EVENT_VIEWER_URL || 'https://app.flotilla.social'
export const BRAND_ACCENT = process.env.BRAND_ACCENT || '#7161FF'
export const BRAND_NAME = process.env.BRAND_NAME || 'Flotilla'
export const BRAND_LOGO =
process.env.BRAND_LOGO || `${EVENT_VIEWER_URL.replace(/\/$/, '')}/logo.png`
export const appSigner = Nip01Signer.fromSecret(process.env.MAILSHIP_SECRET)
export const DEFAULT_RELAYS = process.env.DEFAULT_RELAYS.split(',').map(normalizeRelayUrl)
export const INDEXER_RELAYS = process.env.INDEXER_RELAYS.split(',').map(normalizeRelayUrl)

View file

@ -1,5 +1,16 @@
import nodemailer from 'nodemailer'
import { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM, MAILSHIP_NAME, BASE_URL } from './env.js'
import {
SMTP_HOST,
SMTP_PORT,
SMTP_USER,
SMTP_PASSWORD,
SMTP_FROM,
BASE_URL,
EVENT_VIEWER_URL,
BRAND_ACCENT,
BRAND_NAME,
BRAND_LOGO,
} from './env.js'
import type { Subscription } from './alert.js'
import { render } from './templates.js'
@ -23,11 +34,32 @@ export const sendConfirm = (sub: Subscription) => {
.sendMail({
from: SMTP_FROM,
to: sub.email,
subject: 'Confirm your email digest',
subject: `Confirm your ${BRAND_NAME} digest`,
html: `
<h3>Welcome to ${MAILSHIP_NAME}!</h3>
<p>Please confirm that you would like to receive ${sub.frequency} digests by clicking the link below:</p>
<p><a href="${href}">Confirm Digest</a></p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f0f2f5;padding:32px 16px;font-family:Inter,Helvetica,Arial,sans-serif;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:480px;background:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e2e8f0;">
<tr>
<td style="padding:28px 32px 8px 32px;text-align:center;">
${BRAND_LOGO ? `<img src="${BRAND_LOGO}" alt="${BRAND_NAME}" style="height:40px;width:auto;max-width:200px;object-fit:contain;" />` : `<h1 style="margin:0;font-size:22px;color:#1e293b;font-weight:700;"><span style="color:${BRAND_ACCENT};">${BRAND_NAME}</span></h1>`}
<p style="margin:4px 0 0;color:#64748b;font-size:14px;">Digest notifications</p>
</td>
</tr>
<tr>
<td style="padding:16px 32px;color:#334155;font-size:15px;line-height:1.6;">
<p style="margin:0 0 12px;">Welcome to ${BRAND_NAME}!</p>
<p style="margin:0 0 12px;">Please confirm that you would like to receive ${sub.frequency} digests by clicking the button below:</p>
<p style="margin:0;text-align:center;">
<a href="${href}" style="display:inline-block;background:${BRAND_ACCENT};color:#ffffff;padding:12px 28px;border-radius:8px;font-weight:600;text-decoration:none;">Confirm Digest</a>
</p>
<p style="margin:16px 0 0;font-size:12px;color:#94a3b8;">Or visit: <a href="${href}" style="color:${BRAND_ACCENT};">${EVENT_VIEWER_URL}</a></p>
</td>
</tr>
</table>
</td>
</tr>
</table>
`,
text: `Please confirm that you would like to receive ${sub.frequency} digests by visiting: ${href}`,
})
@ -51,6 +83,9 @@ export const sendDigest = async (sub: Subscription, variables: Record<string, an
...variables,
name: sub.email.split('@')[0],
unsubscribeUrl: `${BASE_URL}/unsubscribe?token=${sub.key}`,
brandName: BRAND_NAME,
brandAccent: BRAND_ACCENT,
brandLogo: BRAND_LOGO,
}),
})
.catch(error => {