Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ Signing up creates a pending member and sends a confirmation link signed with `S

Sending a post freezes its recipients into `send_recipients` when the send is queued. The cron works through them in batches, one worker at a time per send (a lease), and every message carries an idempotency key, so a retry never sends twice. Each email has a signed RFC 8058 one-click unsubscribe header. Delivery, open, click, bounce and complaint events come back through a verified webhook; bounces and complaints suppress the address.

A member labeled `paused` gets no newsletter from any segment until the label comes off. It is for bringing a cold list back slowly: pause the people who rarely open, then take the label off a batch before each send.

## Accounts

Staff sign in with single-use links that expire in an hour; the link opens the admin, and only a click uses it, so mail scanners cannot. Sessions are HttpOnly cookies. API keys for integrations are stored as hashes and carry a role (admin, editor or author). The owner token (`BOOTSTRAP_TOKEN`) is for first setup and the CLI. State-changing admin calls need a bearer token or the admin app's own header, so a cross-site form cannot make them.
Expand Down
2 changes: 1 addition & 1 deletion packages/admin/src/pages/newsletters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export function SendDialog({ post, onClose }: { post: Post; onClose: () => void
<div class="send-grid">
<div class="stack">
{testMode ? <div class="note">Test mode is on: a newsletter goes only to the team members of the segment you pick. Subscribers get nothing.</div> : null}
<Field label="Send to">
<Field label="Send to" hint='People labeled "paused" are left out.'>
<select value={segment} onChange={e => setSegment(e.currentTarget.value)}>
<option value="all">All subscribers</option>
<option value="engaged">Subscribers who have opened an email</option>
Expand Down
5 changes: 4 additions & 1 deletion packages/server/src/newsletter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,11 @@ async function readSize(url: string): Promise<Size | null> {

export type Segment = 'all' | 'engaged' | `label:${string}`;

/** Members with this label get no newsletter, whatever the segment, until it comes off (e.g. while a cold list is brought back a batch at a time). */
export const PAUSED_LABEL = 'paused';

function segmentWhere(segment: Segment): { sql: string; args: unknown[] } {
const base = "status = 'subscribed' AND suppressed IS NULL";
const base = `status = 'subscribed' AND suppressed IS NULL AND instr(labels, '"${PAUSED_LABEL}"') = 0`;
if (segment === 'engaged') return { sql: `${base} AND opened_count > 0`, args: [] };
if (segment.startsWith('label:'))
return { sql: `${base} AND instr(labels, json_quote(?)) > 0 AND EXISTS (SELECT 1 FROM json_each(members.labels) WHERE value = ?)`, args: [segment.slice(6), segment.slice(6)] };
Expand Down
Loading