diff --git a/.env.template b/.env.template index 885b96f..9112de5 100644 --- a/.env.template +++ b/.env.template @@ -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// and / 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 diff --git a/AGENTS.md b/AGENTS.md index d6d9644..9f1d643 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,17 @@ bd close # 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. diff --git a/README.md b/README.md index 563d760..1b04755 100644 --- a/README.md +++ b/README.md @@ -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//` and `/` 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 `/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 diff --git a/package.json b/package.json index 94039eb..b2b588f 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/script/render-preview.mjs b/script/render-preview.mjs new file mode 100644 index 0000000..1e88abc --- /dev/null +++ b/script/render-preview.mjs @@ -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: '

Does anyone know how relay-based groups work?

', + 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: + '

Excited to share our new community space on Flotilla! Check it out.

', + 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}`) diff --git a/src/digest.ts b/src/digest.ts index a693aaa..7e6e0a3 100644 --- a/src/digest.ts +++ b/src/digest.ts @@ -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('')) { @@ -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)) diff --git a/src/emails/digest.mjml b/src/emails/digest.mjml index a6959cd..53f08cc 100644 --- a/src/emails/digest.mjml +++ b/src/emails/digest.mjml @@ -1,36 +1,52 @@ - New Activity + New activity on Flotilla + - .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; } - - + + - Hello {{name}}, - - Below is a summary of activity over the last {{Duration}}. - We found {{Total}} new posts from {{TopProfiles}}. + {{#brandLogo}} + + {{/brandLogo}} + + {{brandName}} - + + + + + + {{UserName}}, here's what's happening in your communities. + + + + - {{#HasPopular}} - Most Popular + {{#HasPopular}} + + + Latest Activity {{#Popular}}
@@ -41,32 +57,33 @@ at {{Timestamp}}
{{{Content}}}
- - {{Replies}} + {{Replies}} replies - - {{Reactions}} + {{Reactions}} reactions
{{/Popular}} - - {{/HasPopular}} +
+
+ {{/HasPopular}} - - You're receiving this email because you subscribed to notifications. - We'll continue to send you updates based on your subscription preferences. + + + + + You're receiving this email because you subscribed to {{brandName}} notifications. + We'll send you updates based on your subscription preferences. - - + Unsubscribe diff --git a/src/env.ts b/src/env.ts index 218e4c2..3588110 100644 --- a/src/env.ts +++ b/src/env.ts @@ -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) diff --git a/src/mailer.ts b/src/mailer.ts index 35739a1..2784599 100644 --- a/src/mailer.ts +++ b/src/mailer.ts @@ -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: ` -

Welcome to ${MAILSHIP_NAME}!

-

Please confirm that you would like to receive ${sub.frequency} digests by clicking the link below:

-

Confirm Digest

+ + + + +
+ + + + + + + +
+ ${BRAND_LOGO ? `${BRAND_NAME}` : `

${BRAND_NAME}

`} +

Digest notifications

+
+

Welcome to ${BRAND_NAME}!

+

Please confirm that you would like to receive ${sub.frequency} digests by clicking the button below:

+

+ Confirm Digest +

+

Or visit: ${EVENT_VIEWER_URL}

+
+
`, 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 { @@ -61,4 +96,4 @@ export const sendDigest = async (sub: Subscription, variables: Record