Commune
API

Publish from wherever you write

Your writing already lives somewhere: a folder of Markdown files, a CMS, a notes app. Copying it into another editor every week is where typos creep in and send times slip. Commune takes the Markdown as it is, lets you check it and send yourself a test, then sends it to your list on time and tells you how it did.

Before you start

Sending from Commune only works for a newsletter that publishes with Commune: its esp is commune. A newsletter sent through another provider has its articles written there and mirrored into Commune afterwards, so creating or editing one answers 422 with the code not_commune_newsletter, and the error's docs_url links to the guide for moving that provider's newsletter onto Commune.

Every request the pipeline makes, in order: find the newsletter, create a draft from Markdown, read it back, change what needs changing, test it, schedule it or send it, and confirm what happened. Then the same thing wired to a repository, so a push to main is all it takes.

Before you start

  • An API key with content: write, sending: write, insights: read, exported as COMMUNE_API_KEY.
  • Node 18 or newer, for the script at the end. Every request also works as the cURL shown, or from the collection.
  • A plan that includes API writes; otherwise every write answers 402. GET /newsletters/{newsletter}/entitlements (with settings: read) tells you in advance.
  • Some steps need more than this. Each one lists it where it starts.

Run it in your client

Every request below, in order, as a collection. Import the file into Postman, Insomnia, Bruno, Yaak or Hoppscotch, set apiKey, and send them one by one.

Download the collection

Every request sends Authorization: Bearer $COMMUNE_API_KEY and Commune-Version: 2026-08-26.

  1. 1

    Find your newsletter

    A key reaches one newsletter or several, whichever its owner ticked when creating it. List them and pick yours by handle (or id, either works in a path) for the requests below, and check esp: only commune can be written to and sent from here.

    GET/newslettersReference
    cURL
    curl "https://api.usecommune.com/newsletters" \
      -H "Authorization: Bearer $COMMUNE_API_KEY" \
      -H "Commune-Version: 2026-08-26"
    Response 200
    {
      "object": "list",
      "data": [
        {
          "object": "newsletter",
          "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
          "handle": "example-letter",
          "name": "The Example Letter",
          "description": "A weekly letter about how newsletters and communities fit together.",
          "esp": "commune",
          "image_url": "https://cdn.example.com/newsletters/example-letter/avatar.png",
          "website_url": "https://example.com",
          "social_links": {
            "twitter": "https://x.com/exampleletter",
            "bluesky": "https://bsky.app/profile/exampleletter.bsky.social"
          },
          "language": "en",
          "chat_create_permission": "subscribers",
          "allow_non_subscriber_chat": false,
          "owner": {
            "object": "user",
            "id": "usr_2Nf8Kq1pWc"
          },
          "featured_article": {
            "object": "article",
            "id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f"
          },
          "created_at": "2025-03-04T10:00:00Z",
          "updated_at": "2026-08-26T09:32:11Z"
        }
      ],
      "pagination": {
        "has_more": false,
        "next_cursor": null
      }
    }
  2. 2

    Create the article from Markdown

    The body goes in content_markdown, as Markdown: headings, emphasis, links, images, quotes, lists, code, tables. HTML is not accepted, and raw HTML inside the Markdown is refused rather than passed through (write a literal < as \<). Commune parses it strictly: a line it cannot read answers 400 naming the line, and nothing is written. Merge tags such as {{ subscriber.first_name }} are kept exactly as written.

    The body is stored exactly as sent, so it has to carry its own footer: an unsubscribe link and your mailing address. <EmailOnly> keeps them in the email and off the web page. A send refuses a body without them.

    What comes back is always a draft. Nothing reaches anybody until step 6 or 7. Set slug yourself when the article comes from a file: a slug already taken answers 422 instead of becoming something else, which is what stops a pipeline that runs twice from making two articles. It is a write, so send an Idempotency-Key.

    POST/newsletters/{newsletter}/articlesReference
    cURL
    curl -X POST "https://api.usecommune.com/newsletters/example-letter/articles" \
      -H "Authorization: Bearer $COMMUNE_API_KEY" \
      -H "Commune-Version: 2026-08-26" \
      -H "Idempotency-Key: 9c2e7f41-6a3b-4d58-8e0f-1b7a5c3d9e26" \
      -H "Content-Type: application/json" \
      -d '{
      "title": "What we learned in March",
      "preview_text": "The third one surprised us.",
      "slug": "what-we-learned-in-march",
      "content_markdown": "# What we learned in March\n\nThree things, and the **third** one surprised us.\n\n<EmailOnly>\n[Unsubscribe]({{ unsubscribe_url }}) | {{ address }}\n</EmailOnly>\n"
    }'
    Response 201
    {
      "object": "article",
      "id": "8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012",
      "short_id": "Vn3Pq8Zt",
      "slug": "what-we-learned-in-march",
      "newsletter": {
        "object": "newsletter",
        "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
      },
      "title": "What we learned in March",
      "preview_text": "The third one surprised us.",
      "image_url": null,
      "external_url": null,
      "status": "draft",
      "is_imported": false,
      "posted_at": null,
      "scheduled_for": null,
      "authors": [
        {
          "object": "user",
          "id": "usr_2Nf8Kq1pWc"
        }
      ],
      "thread": null,
      "stats": {
        "likes": 0,
        "comments": 0,
        "highlights": 0
      },
      "created_at": "2026-09-18T10:22:04Z",
      "updated_at": "2026-09-18T10:22:04Z"
    }
  3. 3

    Read it back

    expand=content adds content_markdown to the article: the body as Commune stored it, in the same Markdown you send. What you read back is what you sent, so this is the check that nothing was lost on the way. content is the email rendered to HTML, with merge tags resolved against an empty context.

    GET/articles/{article}Reference
    cURL
    curl "https://api.usecommune.com/articles/8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012?expand=content" \
      -H "Authorization: Bearer $COMMUNE_API_KEY" \
      -H "Commune-Version: 2026-08-26"
    Response 200
    {
      "object": "article",
      "id": "8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012",
      "short_id": "Vn3Pq8Zt",
      "slug": "what-we-learned-in-march",
      "newsletter": {
        "object": "newsletter",
        "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
      },
      "title": "What we learned in March",
      "preview_text": "The third one surprised us.",
      "image_url": null,
      "external_url": null,
      "status": "draft",
      "is_imported": false,
      "posted_at": null,
      "scheduled_for": null,
      "authors": [
        {
          "object": "user",
          "id": "usr_2Nf8Kq1pWc"
        }
      ],
      "thread": null,
      "stats": {
        "likes": 0,
        "comments": 0,
        "highlights": 0
      },
      "created_at": "2026-09-18T10:22:04Z",
      "updated_at": "2026-09-18T10:22:04Z",
      "content": "<h1>What we learned in March</h1><p>Three things, and the <strong>third</strong> one surprised us.</p>",
      "content_markdown": "# What we learned in March\n\nThree things, and the **third** one surprised us.\n\n<EmailOnly>\n[Unsubscribe]({{ unsubscribe_url }}) | {{ address }}\n</EmailOnly>\n"
    }
  4. 4

    Change what needs changing

    Send only what changes. A property you leave out is left alone, and one you send as null is cleared. content_markdown replaces the whole body, so send all of it, never a fragment.

    A draft, a scheduled article and one whose send failed can be edited. One that is sending, sent, archived or imported answers 422, and so does an empty object. Editing never moves the article's state.

    PATCH/articles/{article}Reference
    cURL
    curl -X PATCH "https://api.usecommune.com/articles/8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012" \
      -H "Authorization: Bearer $COMMUNE_API_KEY" \
      -H "Commune-Version: 2026-08-26" \
      -H "Idempotency-Key: e57b1d93-2f8c-4a06-b3d1-8c4e6f0a2b75" \
      -H "Content-Type: application/json" \
      -d '{
      "preview_text": "Three lessons from March, and the one we did not expect."
    }'
    Response 200
    {
      "object": "article",
      "id": "8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012",
      "short_id": "Vn3Pq8Zt",
      "slug": "what-we-learned-in-march",
      "newsletter": {
        "object": "newsletter",
        "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
      },
      "title": "What we learned in March",
      "preview_text": "Three lessons from March, and the one we did not expect.",
      "image_url": null,
      "external_url": null,
      "status": "draft",
      "is_imported": false,
      "posted_at": null,
      "scheduled_for": null,
      "authors": [
        {
          "object": "user",
          "id": "usr_2Nf8Kq1pWc"
        }
      ],
      "thread": null,
      "stats": {
        "likes": 0,
        "comments": 0,
        "highlights": 0
      },
      "created_at": "2026-09-18T10:22:04Z",
      "updated_at": "2026-09-18T10:31:47Z"
    }
  5. 5

    Send yourself a test

    You'll need

    • A verified sending address on the newsletter. Test sends and real sends both refuse without one.

    With no addresses, the copy goes to you: the person the key belongs to. Your address is not repeated in the answer, which says sent_to_owner: true instead. Name up to five addresses in to to send it to someone else. The copy is rendered the way the real send renders it, with example data in the merge tags, a subject marked as a test and an unsubscribe link that cannot unsubscribe anybody. Nobody on the list receives anything and the article does not change, so test as often as you like. Tests do count towards the daily sending allowance.

    A test only needs a verified sending address and a body. It does not check the footer or the images, so it is exactly where to look for a footer that went missing before the real send refuses it.

    POST/articles/{article}/test-sendReference
    cURL
    curl -X POST "https://api.usecommune.com/articles/8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012/test-send" \
      -H "Authorization: Bearer $COMMUNE_API_KEY" \
      -H "Commune-Version: 2026-08-26" \
      -H "Idempotency-Key: 3a8d0c56-7e1f-4b92-a4c7-5d2e9f1b6a08" \
      -H "Content-Type: application/json" \
      -d '{}'
    Response 200
    {
      "object": "test_send",
      "article": {
        "object": "article",
        "id": "8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012"
      },
      "recipients": [],
      "sent_to_owner": true,
      "sent": 1,
      "failed": 0
    }
  6. 6

    Schedule it

    Give it a time in the future and it is queued. status becomes scheduled, posted_at stays null, and it stays invisible to readers until it goes out. Commune sends queued articles in passes, so the time is honoured to within a few minutes rather than to the second. Scheduling it again moves it; taking it off the schedule is POST /articles/{article}/unschedule.

    Every check the real send makes happens now, not at nine in the morning. A body without its unsubscribe link or address answers 422 (missing_footer), and so does an image that definitely will not load (broken_images), naming the URLs. Fix the image, or set acknowledge_broken_images to go ahead anyway; any later edit to the body clears that acknowledgement. There is no way past the footer check.

    POST/articles/{article}/scheduleReference
    cURL
    curl -X POST "https://api.usecommune.com/articles/8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012/schedule" \
      -H "Authorization: Bearer $COMMUNE_API_KEY" \
      -H "Commune-Version: 2026-08-26" \
      -H "Idempotency-Key: 71f4c2e8-0d5a-4b37-9c61-e3a8b0d4f2c9" \
      -H "Content-Type: application/json" \
      -d '{
      "scheduled_for": "2026-10-01T09:00:00Z"
    }'
    Response 200
    {
      "object": "article",
      "id": "8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012",
      "short_id": "Vn3Pq8Zt",
      "slug": "what-we-learned-in-march",
      "newsletter": {
        "object": "newsletter",
        "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
      },
      "title": "What we learned in March",
      "preview_text": "Three lessons from March, and the one we did not expect.",
      "image_url": null,
      "external_url": null,
      "status": "scheduled",
      "is_imported": false,
      "posted_at": null,
      "scheduled_for": "2026-10-01T09:00:00Z",
      "authors": [
        {
          "object": "user",
          "id": "usr_2Nf8Kq1pWc"
        }
      ],
      "thread": null,
      "stats": {
        "likes": 0,
        "comments": 0,
        "highlights": 0
      },
      "created_at": "2026-09-18T10:22:04Z",
      "updated_at": "2026-09-18T10:40:02Z"
    }
  7. 7

    Or send it now

    Instead of step 6, when it should go out straight away. It cannot be undone. The answer is 202: the article is queued for immediate dispatch, with status scheduled and scheduled_for set to the moment it was queued, and sending begins within a few minutes. The same checks apply as for a schedule, and an article that is already sending or sent answers 422.

    The body is optional and usually left out. Its only field is acknowledge_broken_images.

    POST/articles/{article}/sendReference
    cURL
    curl -X POST "https://api.usecommune.com/articles/8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012/send" \
      -H "Authorization: Bearer $COMMUNE_API_KEY" \
      -H "Commune-Version: 2026-08-26" \
      -H "Idempotency-Key: c0e93b17-5f2d-4a84-b6e8-2a9d7c1f3e50"
    Response 202
    {
      "object": "article",
      "id": "8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012",
      "short_id": "Vn3Pq8Zt",
      "slug": "what-we-learned-in-march",
      "newsletter": {
        "object": "newsletter",
        "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
      },
      "title": "What we learned in March",
      "preview_text": "Three lessons from March, and the one we did not expect.",
      "image_url": null,
      "external_url": null,
      "status": "scheduled",
      "is_imported": false,
      "posted_at": null,
      "scheduled_for": "2026-09-18T10:40:02Z",
      "authors": [
        {
          "object": "user",
          "id": "usr_2Nf8Kq1pWc"
        }
      ],
      "thread": null,
      "stats": {
        "likes": 0,
        "comments": 0,
        "highlights": 0
      },
      "created_at": "2026-09-18T10:22:04Z",
      "updated_at": "2026-09-18T10:40:02Z"
    }
  8. 8

    Confirm it went out

    A send is one run of the dispatch, and article narrows the list to this article's runs (by id; the short id is not accepted here). completed_at is null while recipients are still being handed over; once it is set, recipient_count, sent_count and failed_count are the numbers the run finished on.

    A recipient the provider refused for a moment is retried during the send. One that still fails is followed up by Commune rather than sent again blindly, because a second copy is worse than a late one.

    GET/newsletters/{newsletter}/sendsReference
    cURL
    curl "https://api.usecommune.com/newsletters/example-letter/sends?article=8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012" \
      -H "Authorization: Bearer $COMMUNE_API_KEY" \
      -H "Commune-Version: 2026-08-26"
    Response 200
    {
      "object": "list",
      "data": [
        {
          "object": "send",
          "id": "2d4f6a8c-1b3e-4c5d-8e7f-9a0b1c2d3e4f",
          "article": {
            "object": "article",
            "id": "8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012"
          },
          "newsletter": {
            "object": "newsletter",
            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
          },
          "started_at": "2026-10-01T09:02:13Z",
          "completed_at": "2026-10-01T09:04:51Z",
          "recipient_count": 12402,
          "sent_count": 12398,
          "failed_count": 4,
          "created_at": "2026-10-01T09:02:10Z"
        }
      ],
      "pagination": {
        "has_more": false,
        "next_cursor": null
      }
    }
  9. 9

    Read how it did

    Both sides in one report. email counts recipients, not events: how many were delivered to, opened at least once, clicked at least once, bounced and unsubscribed from this article. Divide by delivered for the rates a provider quotes. community is what readers did with it on Commune: views, likes, saves, highlights and the discussion under it. It is counted when you ask and keeps rising after the send, so read it again in a week.

    GET/articles/{article}/statsReference
    cURL
    curl "https://api.usecommune.com/articles/8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012/stats" \
      -H "Authorization: Bearer $COMMUNE_API_KEY" \
      -H "Commune-Version: 2026-08-26"
    Response 200
    {
      "object": "article_stats",
      "article": {
        "object": "article",
        "id": "8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012"
      },
      "email": {
        "recipients": 12402,
        "delivered": 12371,
        "opened": 6904,
        "clicked": 1288,
        "bounced": 27,
        "unsubscribed": 9
      },
      "community": {
        "views": 1873,
        "likes": 142,
        "saves": 58,
        "highlights": 71,
        "thread_messages": 34,
        "participants": 19
      }
    }
  10. 10

    Hear when it goes out

    Rather than polling, let Commune tell you. send.completed arrives when a run has handed every recipient to the email provider, with the same three counts as step 8 and the send_id to read it back by. send.failed arrives instead if the dispatch broke, and article.published when the article goes live on the web: the moment to post the link to social media or your site.

    When the send was a send-now request through the API, actor names your key and idempotency_key is the key you sent with it. Otherwise, as for this scheduled run, treat null as ordinary. Register the endpoint under Webhooks in your Commune dashboard.

    Commune posts send.completed to your endpoint:

    event send.completed
    {
      "id": "018f2a90-4444-7000-8000-000000000004",
      "type": "send.completed",
      "api_version": "2026-08-26",
      "occurred_at": "2026-10-01T09:04:51Z",
      "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
      "actor": null,
      "idempotency_key": null,
      "data": {
        "article_id": "8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012",
        "send_id": "2d4f6a8c-1b3e-4c5d-8e7f-9a0b1c2d3e4f",
        "started_at": "2026-10-01T09:02:13Z",
        "completed_at": "2026-10-01T09:04:51Z",
        "recipient_count": 12402,
        "sent_count": 12398,
        "failed_count": 4
      }
    }
  11. 11

    Write posts as files

    Now wire it to where you write. Here that is a repository: one Markdown file per issue under posts/, with a little frontmatter on top. title and preview become the subject and preview line, slug defaults to the file name, and send_at decides what happens: leave it out to keep a draft, set a time to schedule, or write now to send on push.

    The script below adds the unsubscribe footer when the file does not carry one, so the posts stay about the writing.

    posts/what-we-learned-in-march.md
    ---
    title: What we learned in March
    preview: Three lessons from March, and the one we did not expect.
    send_at: 2026-10-01T09:00:00Z
    ---
    # What we learned in March
    
    Three things, and the **third** one surprised us.
  12. 12

    Publish on every push to main

    A GitHub Actions workflow that runs the script for every post added or changed in the push, with the key and the test addresses kept as repository secrets. Each run sends you a test first.

    Push an edit to a post that is still a draft or scheduled and the script updates that article and schedules it again, which re-runs the checks. A post whose article already went out is refused on its slug and never sent twice.

    .github/workflows/publish.yml
    name: Publish to Commune
    on:
      push:
        branches: [main]
        paths: ["posts/**.md"]
    concurrency: publish
    jobs:
      publish:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
            with:
              fetch-depth: 0
          - uses: actions/setup-node@v4
            with:
              node-version: 20
          - name: Publish added or changed posts
            run: |
              for file in $(git diff --name-only --diff-filter=AM "${{ github.event.before }}" "${{ github.sha }}" -- 'posts/*.md'); do
                node publish.mjs "$file"
              done
            env:
              COMMUNE_API_KEY: ${{ secrets.COMMUNE_API_KEY }}
              COMMUNE_TEST_TO: ${{ secrets.COMMUNE_TEST_TO }}
  13. 13

    Check on it from your agent

    Once the pipeline runs, you rarely need to read these responses yourself. Connect your agent to Commune's MCP server and ask in plain words; the tools it gets read your newsletter and change nothing.

    "What is still unsent?" is answered by what_have_i_not_sent_yet: your drafts and scheduled articles in one list, newest date first, each with its status, so you can see that the push queued what you expected. "How did my most recent article perform?" is answered by how_did_my_latest_article_do: your newest sent article with the same report as step 9, delivered, opened, clicked, bounced and unsubscribed on the email side, and views, likes, saves, highlights, replies and participants on the community side.

All together

Steps 1 to 7 as one file, with no dependencies: node publish.mjs posts/what-we-learned-in-march.md. It reads the frontmatter, updates the unsent article with the same slug or creates a new one, sends a test to COMMUNE_TEST_TO (a comma separated list, five at most), then keeps it as a draft, schedules it or sends it. Each write carries its own Idempotency-Key, reused when that request is retried, so a retry never makes a change twice.

publish.mjs
import { readFile } from "node:fs/promises";
import { basename } from "node:path";

const BASE = "https://api.usecommune.com";
const VERSION = "2026-08-26";
const KEY = process.env.COMMUNE_API_KEY;
const TEST_TO = (process.env.COMMUNE_TEST_TO ?? "")
  .split(",")
  .map((address) => address.trim())
  .filter(Boolean)
  .slice(0, 5);

// A write gets one Idempotency-Key, kept across its retries, so a retry
// replays the first answer instead of making the change twice.
async function api(path, { method = "GET", body, key = method === "GET" ? null : crypto.randomUUID() } = {}) {
  const headers = { authorization: `Bearer ${KEY}`, "commune-version": VERSION };
  if (key) headers["idempotency-key"] = key;
  if (body !== undefined) headers["content-type"] = "application/json";
  const res = await fetch(BASE + path, {
    method,
    headers,
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  if (res.status === 429 || (res.status === 409 && res.headers.has("retry-after"))) {
    await new Promise((r) => setTimeout(r, Number(res.headers.get("retry-after") ?? 5) * 1000));
    return api(path, { method, body, key });
  }
  const json = await res.json();
  if (!res.ok) {
    const { code, message, request_id } = json.error;
    throw Object.assign(new Error(`${res.status} ${code}: ${message} (request ${request_id})`), {
      status: res.status,
    });
  }
  return json;
}

async function* all(path) {
  let cursor = null;
  do {
    const sep = path.includes("?") ? "&" : "?";
    const page = await api(cursor ? `${path}${sep}cursor=${encodeURIComponent(cursor)}` : path);
    yield* page.data;
    cursor = page.pagination.next_cursor;
  } while (cursor);
}

// The post: frontmatter on top, Markdown below.
const file = process.argv[2];
const source = await readFile(file, "utf8");
const front = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
const meta = {};
for (const line of (front?.[1] ?? "").split(/\r?\n/)) {
  const pair = line.match(/^([A-Za-z_]+):\s*(.*)$/);
  if (pair) meta[pair[1]] = pair[2].trim().replace(/^(["'])(.*)\1$/, "$2");
}
let markdown = front ? source.slice(front[0].length) : source;
if (!markdown.includes("{{ unsubscribe_url }}")) {
  markdown = `${markdown.trimEnd()}\n\n<EmailOnly>\n[Unsubscribe]({{ unsubscribe_url }}) | {{ address }}\n</EmailOnly>\n`;
}
const slug = (meta.slug ?? basename(file, ".md"))
  .toLowerCase()
  .replace(/[^a-z0-9]+/g, "-")
  .replace(/^-+|-+$/g, "")
  .slice(0, 60)
  .replace(/-+$/, "");

const fields = { content_markdown: markdown };
if (meta.title) fields.title = meta.title;
if (meta.preview) fields.preview_text = meta.preview;
if (meta.image) fields.image_url = meta.image;

// 1. The newsletter this key can see, which has to publish with Commune.
// A key can reach several newsletters. COMMUNE_NEWSLETTER picks one by
// handle; without it, a key that reaches exactly one uses that one.
const { data: newsletters } = await api("/newsletters");
const wanted = process.env.COMMUNE_NEWSLETTER;
const newsletter = wanted
  ? newsletters.find((n) => n.handle === wanted || n.id === wanted)
  : newsletters.length === 1 ? newsletters[0] : null;
if (!newsletter) {
  throw new Error(
    wanted
      ? "This key does not reach " + wanted + "."
      : "This key reaches " + newsletters.length + " newsletters. Set COMMUNE_NEWSLETTER to one handle.",
  );
}
if (newsletter.esp !== "commune") {
  throw new Error(`${newsletter.handle} is sent through ${newsletter.esp}, so Commune cannot send it.`);
}

// 2 and 4. Update the unsent article with this slug, or create one.
let article = null;
for await (const candidate of all(`/newsletters/${newsletter.handle}/articles?status=draft&status=scheduled&limit=100`)) {
  if (candidate.slug === slug) {
    article = candidate;
    break;
  }
}
if (article) {
  article = await api(`/articles/${article.id}`, { method: "PATCH", body: fields });
  console.log(`Updated ${slug} (${article.status}).`);
} else {
  try {
    article = await api(`/newsletters/${newsletter.handle}/articles`, {
      method: "POST",
      body: { ...fields, slug },
    });
    console.log(`Created draft ${slug} (${article.short_id}).`);
  } catch (error) {
    if (error.status === 422) {
      console.log(`${slug}: ${error.message}. Already published? Nothing was sent.`);
      process.exit(0);
    }
    throw error;
  }
}

// 5. A test to your own inbox.
if (TEST_TO.length) {
  const test = await api(`/articles/${article.id}/test-send`, { method: "POST", body: { to: TEST_TO } });
  console.log(`Test sent to ${test.sent} of ${test.recipients.length} addresses.`);
}

// 6 or 7. Keep it as a draft, schedule it, or send it now.
if (!meta.send_at) {
  console.log(`Left as ${article.status}. Add send_at to the frontmatter to schedule it.`);
} else if (meta.send_at === "now") {
  article = await api(`/articles/${article.id}/send`, { method: "POST" });
  console.log(`Queued to send now (${article.scheduled_for}).`);
} else {
  article = await api(`/articles/${article.id}/schedule`, {
    method: "POST",
    body: { scheduled_for: new Date(meta.send_at).toISOString() },
  });
  console.log(`Scheduled for ${article.scheduled_for}.`);
}

Next use cases

See all