Skip to content

Newsletter / Marketing Emails Plugin #75

@olliethedev

Description

@olliethedev

Overview

Add a newsletter and marketing email plugin that manages subscriber lists, campaigns, and transactional sends — without requiring a separate SaaS subscription for basic use cases. Built on top of a pluggable email adapter (Resend, Mailgun, SMTP), so consumers can bring their own sending infrastructure.

Deep integration with the Blog plugin makes this especially powerful: one-click "send this post as a newsletter" is a first-class feature.


Core Features

Subscriber Management

  • Subscriber CRUD (email, name, status: subscribed / unsubscribed / bounced)
  • Opt-in confirmation flow (double opt-in support via lifecycle hooks)
  • Unsubscribe link auto-appended to all outgoing emails
  • Subscriber tags/segments for targeted sends
  • CSV import/export

Campaigns

  • Campaign CRUD (subject, from name, from email, reply-to, body)
  • Rich text + Markdown email body editor (reuse CMS markdown editor)
  • "Send test email" to a single address
  • Schedule campaign for future send
  • Campaign status: draftscheduledsendingsent

Blog Integration

  • "Send as newsletter" button on blog post edit page
  • Auto-populate campaign subject + body from post title + content

Analytics

  • Delivery count, open rate (via tracking pixel), click rate (via redirect links)
  • Per-campaign stats dashboard
  • Subscriber growth chart

Schema

import { createDbPlugin } from "@btst/stack/plugins/api"

export const newsletterSchema = createDbPlugin("newsletter", {
  subscriber: {
    modelName: "subscriber",
    fields: {
      email:       { type: "string",  required: true },
      name:        { type: "string",  required: false },
      status:      { type: "string",  defaultValue: "subscribed" }, // "subscribed" | "unsubscribed" | "bounced"
      tags:        { type: "string",  required: false },            // JSON array
      confirmedAt: { type: "date",    required: false },
      createdAt:   { type: "date",    defaultValue: () => new Date() },
    },
  },
  campaign: {
    modelName: "campaign",
    fields: {
      subject:     { type: "string",  required: true },
      fromName:    { type: "string",  required: true },
      fromEmail:   { type: "string",  required: true },
      replyTo:     { type: "string",  required: false },
      body:        { type: "string",  required: true },  // HTML or Markdown
      status:      { type: "string",  defaultValue: "draft" },
      scheduledAt: { type: "date",    required: false },
      sentAt:      { type: "date",    required: false },
      createdAt:   { type: "date",    defaultValue: () => new Date() },
      updatedAt:   { type: "date",    defaultValue: () => new Date() },
    },
  },
  send: {
    modelName: "send",
    fields: {
      campaignId:   { type: "string", required: true },
      subscriberId: { type: "string", required: true },
      status:       { type: "string", defaultValue: "pending" }, // "pending" | "delivered" | "opened" | "clicked" | "bounced"
      sentAt:       { type: "date",   required: false },
    },
  },
})

Plugin Structure

src/plugins/newsletter/
├── db.ts
├── types.ts
├── schemas.ts
├── query-keys.ts
├── client.css
├── style.css
├── api/
│   ├── plugin.ts               # defineBackendPlugin — subscriber, campaign, send endpoints
│   ├── getters.ts              # listSubscribers, getCampaign, getCampaignStats
│   ├── mutations.ts            # createSubscriber, sendCampaign, unsubscribe
│   ├── query-key-defs.ts
│   ├── serializers.ts
│   ├── email-adapter.ts        # EmailAdapter interface (send(to, subject, html))
│   └── index.ts
└── client/
    ├── plugin.tsx              # defineClientPlugin — admin routes
    ├── overrides.ts            # NewsletterPluginOverrides
    ├── index.ts
    ├── hooks/
    │   ├── use-newsletter.tsx  # useSubscribers, useCampaigns, useCampaignStats
    │   └── index.tsx
    └── components/
        ├── subscribe-form.tsx          # Embeddable opt-in form component
        └── pages/
            ├── subscribers-page.tsx
            ├── subscribers-page.internal.tsx
            ├── campaigns-page.tsx
            ├── campaigns-page.internal.tsx
            ├── edit-campaign-page.tsx
            ├── edit-campaign-page.internal.tsx
            └── campaign-stats-page.tsx

Email Adapter Interface

export interface EmailAdapter {
  send(options: {
    to: string | string[]
    subject: string
    html: string
    from: string
    replyTo?: string
  }): Promise<{ messageId?: string }>
}

// Built-in adapters (thin wrappers):
export function resendAdapter(apiKey: string): EmailAdapter
export function mailgunAdapter(apiKey: string, domain: string): EmailAdapter

Routes

Route Path Description
subscribers /newsletter/subscribers Subscriber list + import/export
campaigns /newsletter/campaigns Campaign list
editCampaign /newsletter/campaigns/:id Campaign editor + send controls
newCampaign /newsletter/campaigns/new New campaign
campaignStats /newsletter/campaigns/:id/stats Per-campaign analytics

Subscribe Form Component

An embeddable component for public-facing subscription forms — not a full plugin route:

import { SubscribeForm } from "@btst/stack/plugins/newsletter/client"

// Drop into any page — hits the newsletter API directly
<SubscribeForm
  apiBaseURL="https://example.com"
  apiBasePath="/api/data"
  tags={["blog-sidebar"]}
  onSuccess={() => toast("You're subscribed!")}
/>

Blog Integration

On edit-post-page.internal.tsx, a "Send as newsletter" action:

  • Pre-fills a new campaign with subject = post.title, body = post.content
  • Navigates to /newsletter/campaigns/new?fromPostId=...

Hooks

newsletterBackendPlugin({
  emailAdapter: resendAdapter(process.env.RESEND_API_KEY!),
  onBeforeSubscribe?:   (email, ctx) => Promise<void>  // throw to reject
  onAfterSubscribe?:    (subscriber, ctx) => Promise<void>
  onBeforeUnsubscribe?: (subscriber, ctx) => Promise<void>
  onAfterSend?:         (campaign, ctx) => Promise<void>
})

Consumer Setup

// lib/stack.ts
import { newsletterBackendPlugin } from "@btst/stack/plugins/newsletter/api"
import { resendAdapter } from "@btst/stack/plugins/newsletter/api"

newsletter: newsletterBackendPlugin({
  emailAdapter: resendAdapter(process.env.RESEND_API_KEY!),
})
// lib/stack-client.tsx
import { newsletterClientPlugin } from "@btst/stack/plugins/newsletter/client"

newsletter: newsletterClientPlugin({
  apiBaseURL: "",
  apiBasePath: "/api/data",
  siteBasePath: "/pages",
  queryClient,
})

Non-Goals (v1)

  • Visual drag-and-drop email builder (use Markdown/HTML body)
  • Automated drip sequences / workflows
  • Subscriber scoring / engagement scoring
  • Bounce handling webhooks (handled by adapter)
  • SMS / push channels

Plugin Configuration Options

Option Type Description
emailAdapter EmailAdapter Sending backend (Resend, Mailgun, SMTP)
doubleOptIn boolean Require confirmation email (default: false)
unsubscribeBaseURL string Base URL for unsubscribe links
hooks NewsletterPluginHooks Lifecycle hooks

Documentation

Add docs/content/docs/plugins/newsletter.mdx covering:

  • Overview — subscriber management + campaign sending, pluggable email adapter
  • SetupnewsletterBackendPlugin with adapter, newsletterClientPlugin
  • Email adapters — Resend, Mailgun examples; custom adapter interface
  • Embeddable subscribe form<SubscribeForm> usage
  • Blog integration — "Send as newsletter" workflow
  • Schema referenceAutoTypeTable for config + hooks
  • Double opt-in — how to enable + confirmation email flow

Related Issues

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions