WorkflowsMCP

Overdue rejection check: Notion → Gmail → Slack

Flags Rejected candidates with no rejection email on file, drafts one, lists them in Slack.

@workflowsmcpVerified recruitinghiringatscandidate-experienceai-drafting

Notion's Send mail to can fire a rejection, but all eight Notion automation actions only push outward — none can read Gmail to see whether a rejection already went out. This polls the shared Candidates database every 6 hours for Stage=Rejected past a configurable window (default 48 hours, so a Friday rejection isn't flagged until Monday), checks sent mail with to: plus your fixed rejection subject, drafts a personalized rejection when nothing matches, and posts a Slack list for humans to review and send.

How it flows

  1. 01

    Scheduled poll checks Notion every 6 hours

    Queries the shared Candidates database for Stage=Rejected. This is a poll, not a Notion automation, because the next steps need to look inside Gmail before deciding anything — and none of Notion's eight automation actions can read another app's state.

  2. 02

    Rows missing a timestamp get one

    Any Stage=Rejected row with an empty Rejected At gets PATCHed with the current time, once, before the age filter runs.

  3. 03

    Filter to rows past the threshold

    Configurable, e.g. 24–72 hours; 48 hours is a reasonable default because it spans a weekend without flagging a Friday-afternoon rejection until Monday morning.

  4. 04

    Gmail sent-mail check, per candidate

    to:"<email>" in:sent subject:("<team's fixed rejection subject>") against the Email on the Notion row. A hit means that subject already went out; a miss only means this exact subject is not on file — not that no human will ever send something else.

  5. 05

    No match → AI drafts a personalized rejection; a match is skipped

    Misses become a Gmail draft built from the candidate's Name and Role. Hits are treated as already handled by hand. The job never calls the send endpoint — that, and not the scope, is what stops it.

  6. 06

    Slack summary posts the whole batch

    One message per run: each waiting candidate, how long they have been waiting, a link to their Gmail draft (if one was created), and a link back to their Notion row. Names are plain text, not @mentions.

  7. 07

    A person opens each draft, edits, and presses send

    The only step that actually emails a candidate. Everything upstream only ever prepares a draft and waits for a human to confirm and send it.

Set up each app

Work through these in order — later apps usually need a token or an id from an earlier one.

Notion

The candidate row — Stage and the new Rejected At clock

  1. 01

    Reuse the shared Candidates database — do not create a second one

    Read and write the same Candidates database your recruiting pipeline already uses: Name (Title), Email (Email), Role (Select), Stage (Status: Applied → Screen → Onsite → Offer → Hired / Rejected), Source (Select: Inbound / Referral / Outbound), Résumé (File), Applied On (Date), Screen Booked (Date), Panel (Person), Notes (Text). Reuse the same internal connection if one already exists for this workspace. A second candidate database for the same pipeline is the fastest way to get one person split across two rows nobody trusts.

  2. 02

    Add the "Rejected At" property

    The shared schema has no field for when Stage became Rejected. Notes is free text and unreliable; Notion's built-in Last edited time re-stamps on any later edit — even a stray note — so it cannot anchor a 48-hour clock. Add one Date property named exactly Rejected At. It is additive and does not change how the recruiting pipeline already reads or writes the rest of the row.

  3. 03

    Default path: let the poll job stamp Rejected At itself

    First query Stage=Rejected with Rejected At empty (empty dates typically do not satisfy date.before, so the overdue query alone never surfaces rows that still need a stamp). When a row matches, PATCH the page once with the current timestamp before it enters the age filter. This works on every Notion plan, including free. Only write when the field is still empty so a later run never overwrites an existing stamp. A 6-hour poll means the stamp can lag the real Stage change by up to one poll interval — acceptable against a multi-day threshold, and the path whose evidence chain does not depend on undocumented automation behaviour.

    Empty Rejected At pass, then PATCH /v1/pages/{page_id}
    {
      "filter": {
        "and": [
          { "property": "Stage", "status": { "equals": "Rejected" } },
          { "property": "Rejected At", "date": { "is_empty": true } }
        ]
      }
    }
    
    # Then PATCH each hit only if Rejected At is still empty:
    # {
    #   "properties": {
    #     "Rejected At": { "date": { "start": "2026-08-05T09:00:00.000Z" } }
    #   }
    # }
  4. 04

    Grant Read content and Update content, then query the overdue rows

    On the shared connection's Capabilities tab, turn on Read content (to find Stage=Rejected rows) and Update content (the poll job stamps Rejected At itself — see the previous step). If the database is not already shared with this connection, Database → ••• → Connections → Add connection. Only after the empty-Rejected-At stamp pass, query Stage=Rejected with Rejected At before now−threshold using the snippet below. Query with Notion-Version: 2026-03-11 against POST /v1/data_sources/{data_source_id}/query — retrieve the database once for its data source id; /v1/databases/{id}/query still answers but is documented as deprecated. The before value is "now minus your threshold hours" as an ISO timestamp, not a hard-coded date.

    POST /v1/data_sources/{data_source_id}/query — Notion-Version: 2026-03-11
    {
      "filter": {
        "and": [
          { "property": "Stage", "status": { "equals": "Rejected" } },
          { "property": "Rejected At", "date": { "before": "2026-08-07T00:00:00.000Z" } }
        ]
      },
      "page_size": 50
    }
  5. 05

    Optional, paid plans only: try a more precise stamp — then fall back

    Notion database automations (Plus/Business/Enterprise — not on the free plan) can fire Property edited on Stage and run Edit property the instant it changes. Whether that action can write "the current time" vs only a fixed value someone types in is unverified in Notion's own docs — confirm it once in your workspace before relying on it. If the editor has no current-date option, or you are on the free plan, keep the poll-job PATCH above as the path this workflow ships with.

Gmail

Checks whether a rejection was really sent, and drafts one when it was not

  1. 01

    Enable the Gmail API and request two scopes

    Google Cloud Console → APIs & Services → enable the Gmail API → OAuth consent screen → Credentials → OAuth client (reuse an existing client for this Google account if you already have one for hiring). Request gmail.readonly for the sent-mail check and gmail.compose for the draft. Google documents gmail.compose as "Manage drafts and send emails", so it permits sending. Nothing in the credential stops a send. This workflow never sends because the job never calls users.messages.send — that is a property of the code, not of the key, and there is no key that behaves otherwise, because drafting and sending share a scope. Second caveat: gmail.compose is a restricted scope. Against your own mailbox — an internal Workspace app, or an unpublished project with yourself as the test user — that is fine. Publishing it to outside users triggers OAuth app verification and, if you store or transmit the data, a CASA security assessment renewed every year.

    Scopes
    https://www.googleapis.com/auth/gmail.readonly
    https://www.googleapis.com/auth/gmail.compose
  2. 02

    Agree on one fixed rejection subject line before turning this on

    to:"<email>" in:sent alone only proves the candidate got some email from you — most rejected candidates already received an interview invite, so that check alone reads the old invite as "rejection sent" and skips someone who was never told. Have the team agree on one fixed subject template, e.g. "Update on your application — {Role} at {Company}", and use it on every real rejection so the query can tell the two apart. Quoting the address makes the match exact. Two limits to write down, not paper over: this lowers false skips, it does not guarantee a perfect check — a hand-written rejection with a different subject will not match and can produce a redundant draft the recruiter simply ignores; and to: does not match Cc or Bcc (add OR cc:"<email>" if your team sometimes sends that way).

    GET /gmail/v1/users/me/messages?q=...
    to:"lee@northwind.co" in:sent subject:("Update on your application")
  3. 03

    Draft the rejection when no matching send is found — a person still presses send

    When the subject-filtered search comes back empty, have the AI draft a personalized rejection from the candidate's Name and Role on the Notion row and create it as a Gmail draft — never sent automatically. A person opens the draft, edits it, and presses send themselves; that manual send is the only send this workflow ever produces. The drafts.create call returns an id rather than a URL, so build the browser link as https://mail.google.com/mail/u/0/#drafts?compose=<id>. Optionally apply a label such as Rejections/Drafted for an audit trail. Encode the RFC 2822 message as base64url (the -_ alphabet, not +/) with CRLF line breaks.

    POST /gmail/v1/users/me/drafts
    {
      "message": {
        "raw": "VG86IGxlZUBub3J0aHdpbmQuY28NClN1YmplY3Q6IFVwZGF0ZSBvbiB5b3VyIGFwcGxpY2F0aW9uIC0gU2VuaW9yIEVuZ2luZWVyIGF0IEFjbWUNCg0KSGkgTGVlLC4uLg=="
      }
    }

Slack

Posts the run summary for the team to work from

  1. 01

    Create or reuse a bot with chat:write, invite it to the channel

    api.slack.com/apps → Create New App → From scratch → OAuth & Permissions → Bot Token Scopes → add chat:write → Install to Workspace → copy the Bot User OAuth Token. Invite it into your hiring channel with /invite @your-bot-name, or chat.postMessage returns not_in_channel. Slack answers HTTP 200 even on auth failure — read the body's ok / error fields (not_authed means the endpoint is alive and the token is wrong).

  2. 02

    Names in the summary are plain text, not @mentions

    The shared Candidates database has a Panel property (Person) but no Recruiter or Owner property, so there is no reliable person to ping without adding a field to a schema other hiring workflows already share. Post candidate names as plain text for the team to scan, with links back to each Gmail draft and Notion row so anyone can open and act — no @mentions, no real pings.

    POST https://slack.com/api/chat.postMessage
    {
      "channel": "#hiring",
      "text": "3 candidates rejected 48h+ with no rejection email on file — drafts ready to review:\n• Lee Okafor (Senior Engineer, 3d) — draft: <gmail-draft-link> · row: <notion-link>\n• …"
    }