# 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.

How: with the API (About 45 minutes to wire up a repository).

What you will have:

- An article created in Commune from the Markdown you already write, without retyping it.
- A test copy in your own inbox before anyone on the list sees it.
- The article scheduled for the time you choose, or sent straight away.
- The send confirmed, and the numbers it finished on: delivered, opened, clicked, and what the community did with it.
- A GitHub Action that does all of it when you push a post to main.

**Warning:** 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.

## With the API

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 from https://usecommune.com/settings/api-keys with: content: write, sending: write, insights: read. Export it 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.

Every request sends `Authorization: Bearer $COMMUNE_API_KEY` and `Commune-Version: 2026-08-26`. The requests below are also a collection you can import into Postman, Insomnia, Bruno, Yaak or Hoppscotch: https://usecommune.dev/use-cases/publish-from-anywhere/collection.json

### 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 /newsletters` ([reference](https://api-reference.usecommune.dev/operation/operation-listnewsletters))

```sh
curl "https://api.usecommune.com/newsletters" \
  -H "Authorization: Bearer $COMMUNE_API_KEY" \
  -H "Commune-Version: 2026-08-26"
```

Response `200`:

```json
{
  "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. 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}/articles` ([reference](https://api-reference.usecommune.dev/operation/operation-createarticle))

```sh
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`:

```json
{
  "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. 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](https://api-reference.usecommune.dev/operation/operation-getarticle))

```sh
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`:

```json
{
  "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. 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](https://api-reference.usecommune.dev/operation/operation-updatearticle))

```sh
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`:

```json
{
  "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. Send yourself a test

You will 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-send` ([reference](https://api-reference.usecommune.dev/operation/operation-sendarticletest))

```sh
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`:

```json
{
  "object": "test_send",
  "article": {
    "object": "article",
    "id": "8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012"
  },
  "recipients": [],
  "sent_to_owner": true,
  "sent": 1,
  "failed": 0
}
```

### 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}/schedule` ([reference](https://api-reference.usecommune.dev/operation/operation-schedulearticle))

```sh
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`:

```json
{
  "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. 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}/send` ([reference](https://api-reference.usecommune.dev/operation/operation-sendarticle))

```sh
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`:

```json
{
  "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. 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}/sends` ([reference](https://api-reference.usecommune.dev/operation/operation-listnewslettersends))

```sh
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`:

```json
{
  "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. 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}/stats` ([reference](https://api-reference.usecommune.dev/operation/operation-getarticlestats))

```sh
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`:

```json
{
  "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. 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](https://usecommune.com/dashboard/webhooks) in your Commune dashboard.

Event `send.completed`, as Commune posts it to your endpoint:

```json
{
  "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. 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:

```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. 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:

```yaml
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. 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.

```js
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}.`);
}
```
