Newsletter draft & approval: Notion → Slack → Gmail
Ready DMs the editor; one Approve opens a Gmail draft — you still click Send.
Notion's native Send mail to fires the moment Status flips — straight to inboxes, no draft to review, and only on Plus, Business, and Enterprise. This workflow waits: Ready fires a database automation webhook, Slack DMs the Approver with a preview and Approve/Reject, and only Approve re-reads the Notion page into a Gmail draft addressed to your Send To list. Nothing leaves Drafts until a person clicks Send. One content lead, one yes — not a sequential multi-approver chain.
How it flows
- 01
Status flips to Ready
The database automation posts to your webhook with page id and the checked properties (Title, Approver, Send To) — never the page body. Receiver rejects any POST missing the shared X-Newsletter-Secret header.
- 02
Approver gets a DM with a live preview
Handler fetches blocks once for the preview, resolves Approver email → Slack id, opens a DM, and posts Approve & draft / Reject. Button value carries the Notion page id.
- 03
Click acknowledged within 3 seconds
Interactivity Request URL returns HTTP 200 immediately; Notion re-fetch and Gmail work run after the ack. Approver must confirm before any draft is created.
- 04
Approve re-fetches blocks, then builds the draft body
Do not reuse the preview fetch. Notion-hosted file URLs are temporary public links valid for 1 hour — an Approver who clicks later than that would otherwise get broken images in the mail. Re-call GET /v1/blocks/{page_id}/children now, map the supportable subset to HTML + plain text, and skip unsupported blocks (optionally append a one-line "[N unsupported blocks omitted]" note).
- 05
Gmail draft created; Notion marked Drafted
drafts.create with To = Send To. On success, PATCH the page: Status → Drafted. Optionally PATCH Draft URL. `drafts.create` returns an id, not a browser URL — build a compose link from that id (or skip the URL property and leave users to open Drafts in Gmail). A person opens Gmail and clicks Send — that click is the final gate.
- 06
Reject returns the row to Drafting
PATCH Status → Drafting. Gmail untouched.
Set up each app
Work through these in order — later apps usually need a token or an id from an earlier one.
Notion
Newsletter source of truth, Ready trigger, and status write-back
- 01
Build the "Newsletter" database
Create a new page → Table (or type /database). Properties — names matter, the API and the automation both key off them: Title (Title), Status (Status: Idea → Drafting → Ready → Drafted → Sent; put Idea/Drafting under To-do, Ready/Drafted under In Progress, Sent under Complete), Approver (Person), Send To (Email or Text — a Google Group alias such as list-newsletter@acme.com is the usual value), Draft URL (URL, optional, for the Gmail draft link write-back). Write the newsletter body in the page body itself, not in a property: database webhook actions can only carry page properties, never page contents.
- 02
Create an internal integration and share the database
notion.so/my-integrations → New integration → pick the workspace → Capabilities: check Read content (to pull blocks after Ready and again after Approve) and Update content (to write Status back); leave Insert content off — this flow never creates pages. Copy the Internal Integration Secret. Then open the Newsletter database → ••• (top right) → Connections → Add connection → your integration. Skip the share and every GET/PATCH returns 404 on a database you can plainly see. The Send webhook action itself needs no Notion API key; the integration is only for the pull-and-write-back half.
- 03
Automate Status = Ready → Send webhook (or poll on Free)
Open the database → lightning bolt (Automations, top right) → New automation → Trigger: Status → Ready → Add action → Send webhook → paste your public receiver URL. Add a custom header such as X-Newsletter-Secret with a random shared value — Notion's Send webhook action does not sign the outbound POST, so the receiver must check this header or the endpoint is open to anyone who guesses the URL. Under "select properties to include", check Title, Approver, and Send To; page body still will not travel, by design. Save. Periodically open the Automations panel: after repeated delivery failures Notion auto-pauses the automation and you have to Resume it by hand. Free-tier alternative when database automations are unavailable: skip this webhook and every 5–15 minutes first GET `/v1/databases/{database_id}` and read `data_sources[].id` — that is the `{data_source_id}` below (`/v1/databases/{id}/query` is deprecated). Then POST /v1/data_sources/{data_source_id}/query with Notion-Version 2026-03-11 filtering Status equals Ready, then hand each page id to the same receiver. Prefer the webhook whenever automations are available — Ready is a one-shot edit, not a time-window aggregation, so the automation can filter at the source.
Free-tier poll — POST /v1/data_sources/{data_source_id}/query · Notion-Version: 2026-03-11{ "filter": { "property": "Status", "status": { "equals": "Ready" } } } - 04
Pull page blocks for the approval preview (and again after Approve)
Webhook payload carries page id plus the properties you checked — never the body. For the Slack preview, and again after Approve before building the Gmail draft, call GET /v1/blocks/{page_id}/children with Notion-Version 2026-03-11. Paginate with start_cursor while has_more is true (cap 1000 blocks / 500KB per request). Map a supportable subset only: paragraph → <p>, heading_1/2/3 → <h1>/<h2>/<h3>, bulleted/numbered lists → <ul>/<ol><li>, Notion-hosted image (type file) → <img> using the URL from this fetch, external image → <img> as-is, divider → <hr>, quote → <blockquote>. Skip toggle, column, table, callout, embed. Also assemble a plain-text twin from rich_text[].plain_text so the MIME can be multipart/alternative. Do not cache Notion-hosted file URLs across the approval wait — each fetch returns a temporary public URL valid for 1 hour.
GET /v1/blocks/{page_id}/children · Notion-Version: 2026-03-11curl -s "https://api.notion.com/v1/blocks/<page_id>/children?page_size=100" \ -H "Authorization: Bearer $NOTION_TOKEN" \ -H "Notion-Version: 2026-03-11"
Slack
One-shot DM approval from the content lead
- 01
Create the app and bot scopes
api.slack.com/apps → Create New App → From scratch → OAuth & Permissions → Bot Token Scopes. Add the three scopes below, Install to Workspace, copy the Bot User OAuth Token. This is a private DM to the Approver, not a channel broadcast — no channels:* scopes.
Bot Token Scopeschat:write # post the approval DM users:read.email # resolve Approver email → Slack user id (users:read alone is not enough) im:write # conversations.open a DM with that user - 02
Turn Interactivity on and set the Request URL
Same app → Interactivity & Shortcuts → toggle Interactivity on → paste your Request URL (same backend as the Notion webhook, or a sibling route). Skip this and both buttons are inert. Slack POSTs application/x-www-form-urlencoded with a payload parameter that is a JSON string; acknowledge with HTTP 200 inside 3 seconds, then do the heavy work (re-fetch Notion, create the Gmail draft) asynchronously.
- 03
DM the Approver with Approve / Reject buttons
Read Approver from the Notion Person property (it includes email), call users.lookupByEmail, then conversations.open with that single user id and post to the returned channel.id. Put a short preview of the first ~2 paragraphs in the message so review does not require opening Notion on a phone. Button value carries the Notion page id (prefix notion:…) so the interactivity handler needs no lookup table. action_id values below are examples; keep them stable in your handler.
chat.postMessage — approval DM blocks{ "channel": "<approver-dm-channel-id>", "text": "Newsletter ready for approval: <title>", "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": "*Newsletter ready for review*\n\n<preview of first ~2 paragraphs>" } }, { "type": "actions", "elements": [ { "type": "button", "style": "primary", "text": { "type": "plain_text", "text": "Approve & draft" }, "value": "notion:2f91c4a70b8d4e5f9a1c3b6d8e0f2a4b", "action_id": "newsletter_approve" }, { "type": "button", "style": "danger", "text": { "type": "plain_text", "text": "Reject" }, "value": "notion:2f91c4a70b8d4e5f9a1c3b6d8e0f2a4b", "action_id": "newsletter_reject" } ] } ] }
Gmail
Holds the approved draft until a human sends it
- 01
Enable the Gmail API and configure the OAuth consent screen
Google Cloud Console → create or pick a project → APIs & Services → Library → enable Gmail API → OAuth consent screen. Newsletter senders are usually a marketing mailbox, so user-level OAuth is enough — domain-wide delegation is not required.
- 02
Create an OAuth client scoped to gmail.compose only
Credentials → Create OAuth client. Request only the `gmail.compose` scope (full value in the snippet). Do not request `gmail.modify` — it adds mailbox-read permission this flow does not need. Important: gmail.compose itself can call the send API; "draft only, human sends" is a code-path promise, not an OAuth hard limit. Keep messages.send out of the handler entirely.
gmail.compose scopehttps://www.googleapis.com/auth/gmail.compose - 03
Authorize once, then only call drafts.create
Run the OAuth consent flow once for the sending mailbox and store the refresh token. On Approve, POST /gmail/v1/users/me/drafts with a message.raw body that is the base64url encoding of an RFC 2822 multipart/alternative message: To from the Notion Send To property, Subject from the page Title, text/plain and text/html parts from the block mapping above. Build the MIME with your language's MIME library — do not hand-concatenate the raw string. After create succeeds, a person opens that draft in Gmail and clicks Send; the API never does.
POST https://gmail.googleapis.com/gmail/v1/users/me/drafts{ "message": { "raw": "<base64url(MIME multipart/alternative:\n To: list-newsletter@acme.com\n Subject: <Notion page title>\n text/plain + text/html parts from the re-fetched blocks)>" } }