Send email from Linear workflows — with approval before delivery

Linear tells your agent when work changed state. MultiMail decides whether that change justifies an outbound email, and makes a human confirm before it goes out.


Linear is the issue tracker and project coordination tool most engineering teams actually use. Agents built on top of Linear data — triage bots, release coordinators, customer escalation handlers — frequently need to send email when something changes: a P0 closes, a cycle slips, a release ships. The problem is that Linear state changes do not map cleanly onto email-worthy events. An agent that emails customers every time an issue moves to Done will burn trust fast.

MultiMail sits between your Linear agent and your outbox. When the agent decides an email should go out, MultiMail captures that intent and holds it for human review under `gated_send` mode, or logs it with full auditability under `monitored` mode. Your team gets to confirm that the agent's judgment is correct before anything reaches a customer or teammate.

The integration is a webhook handler plus a few MultiMail API calls. Linear fires a webhook on issue state changes, cycle transitions, or project updates. Your handler reads the Linear context, decides whether an email is warranted, and calls `POST /send_email` — or queues the decision for a human to approve via `list_pending` and `decide_email`.

Built for Linear developers

State changes are not email triggers

A Linear issue moving from In Progress to Done is internal signal, not a customer event. MultiMail's `gated_send` mode lets your agent propose the email while a human confirms it's appropriate — preventing notification spam from automated workflows.

Full audit trail on every outbound notification

Every email sent from a Linear-triggered agent is logged with the originating issue ID, the agent's reasoning, and approval timestamps. When a customer asks why they received a message, you have a complete record tied back to a specific Linear event.

Oversight mode per mailbox

Set `autonomous` for internal Slack-replacement notifications, `gated_send` for customer-facing release notes, and `read_only` for agents that should only report what they'd send. Oversight mode is configured per mailbox, not per request.

Approval webhooks close the loop

MultiMail fires webhooks on approval events. Your agent can listen for `email.approved` and `email.rejected` to update the Linear issue — adding a comment, changing state, or notifying the assignee — without polling.

No sending infrastructure to operate

MultiMail handles deliverability, DKIM signing, bounce tracking, and unsubscribe headers required by CAN-SPAM and GDPR. Your Linear agent calls one API endpoint; MultiMail handles the rest.


Get started in minutes

Handle a Linear webhook and propose an email
typescript
import { Hono } from 'hono';
import { LinearClient } from '@linear/sdk';

const app = new Hono();
const linear = new LinearClient({ apiKey: process.env.LINEAR_API_KEY! });

app.post('/webhooks/linear', async (c) => {
  const body = await c.req.json();

  "cm">// Only act on issue state transitions to Done
  if (body.type !== 'Issue' || body.action !== 'update') {
    return c.json({ ok: true });
  }

  const updatedState = body.updatedFrom?.stateId;
  const newState = body.data?.state?.name;
  if (newState !== 'Done') return c.json({ ok: true });

  const issue = await linear.issue(body.data.id);
  const labels = await issue.labels();
  const isBug = labels.nodes.some(l => l.name.toLowerCase() === 'bug');
  if (!isBug) return c.json({ ok: true });

  "cm">// Propose notification — held for human approval under gated_send
  const res = await fetch('https://api.multimail.dev/send_email', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.MULTIMAIL_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      from: '[email protected]',
      to: body.data.subscriberEmails ?? [],
      subject: `Bug fixed: ${issue.title}`,
      text: `The issue "${issue.title}" (${body.data.id}) has been resolved.\n\nDetails: ${issue.url}`,
      metadata: {
        linear_issue_id: body.data.id,
        linear_team: body.data.team?.name,
        trigger: 'issue_done',
      },
    }),
  });

  const result = await res.json();
  console.log('MultiMail queued:', result.id, 'status:', result.status);
  return c.json({ ok: true, email_id: result.id });
});

export default app;

A Hono/Express handler that receives Linear issue state change webhooks and proposes a customer notification via MultiMail when a bug moves to Done.

Poll pending approvals and update Linear on decision
typescript
import { LinearClient } from '@linear/sdk';

const linear = new LinearClient({ apiKey: process.env.LINEAR_API_KEY! });

async function syncApprovalDecisions() {
  const res = await fetch('https://api.multimail.dev/list_pending', {
    headers: { 'Authorization': `Bearer ${process.env.MULTIMAIL_API_KEY}` },
  });
  const { emails } = await res.json() as { emails: Array<{
    id: string;
    status: 'pending' | 'approved' | 'rejected';
    metadata: { linear_issue_id?: string };
    to: string[];
    subject: string;
  }> };

  for (const email of emails) {
    if (email.status === 'pending') continue;
    if (!email.metadata?.linear_issue_id) continue;

    const issue = await linear.issue(email.metadata.linear_issue_id);
    const commentBody = email.status === 'approved'
      ? `✓ Customer notification sent to ${email.to.join(', ')}: "${email.subject}" [MultiMail ${email.id}]`
      : `✗ Customer notification rejected: "${email.subject}" [MultiMail ${email.id}]`;

    await linear.createComment({
      issueId: issue.id,
      body: commentBody,
    });

    console.log(`Updated Linear issue ${issue.identifier}: ${email.status}`);
  }
}

"cm">// Run every 5 minutes via your scheduler of choice
setInterval(syncApprovalDecisions, 5 * 60 * 1000);
syncApprovalDecisions();

A background job that fetches pending MultiMail emails, then updates the originating Linear issue with a comment once the email is approved or rejected.

Cycle completion digest with `gated_all` mode
typescript
import { LinearClient } from '@linear/sdk';

const linear = new LinearClient({ apiKey: process.env.LINEAR_API_KEY! });

export async function sendCycleDigest(teamId: string, recipients: string[]) {
  const team = await linear.team(teamId);
  const cycles = await team.activeCycle;
  if (!cycles) throw new Error(`No active cycle for team ${teamId}`);

  const issues = await cycles.issues();
  const completed = issues.nodes.filter(i => i.state && i.completedAt);

  if (completed.length === 0) {
    console.log('No completed issues in current cycle — skipping digest');
    return;
  }

  const lines = completed.map(i => `- [${i.identifier}] ${i.title} (${i.assignee?.name ?? 'unassigned'})`);
  const body = [
    `Cycle: ${cycles.name}`,
    `Period: ${cycles.startsAt?.toDateString()} – ${cycles.endsAt?.toDateString()}`,
    '',
    `Completed (${completed.length}):`,
    ...lines,
  ].join('\n');

  const res = await fetch('https://api.multimail.dev/send_email', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.MULTIMAIL_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      from: '[email protected]',
      to: recipients,
      subject: `Cycle digest: ${cycles.name} — ${completed.length} issues completed`,
      text: body,
      metadata: {
        linear_team_id: teamId,
        linear_cycle_id: cycles.id,
        trigger: 'cycle_digest',
      },
    }),
  });

  const result = await res.json();
  "cm">// Under gated_all, status will be 'pending' until a human approves
  console.log(`Digest queued. ID: ${result.id}, status: ${result.status}`);
  return result;
}

Fetch all issues completed in the current cycle and send a digest email to stakeholders. Uses `gated_all` so every send — including the plain text body — is reviewed before delivery.

Read inbox for Linear mention replies
typescript
import { LinearClient } from '@linear/sdk';

const linear = new LinearClient({ apiKey: process.env.LINEAR_API_KEY! });

export async function ingestInboundReplies(mailbox: string) {
  const inboxRes = await fetch(
    `https:"cm">//api.multimail.dev/check_inbox?mailbox=${encodeURIComponent(mailbox)}&unread=true`,
    { headers: { 'Authorization': `Bearer ${process.env.MULTIMAIL_API_KEY}` } }
  );
  const { emails } = await inboxRes.json() as { emails: Array<{ id: string; subject: string; from: string; thread_id: string }> };

  for (const email of emails) {
    "cm">// Read full email to get body + metadata
    const readRes = await fetch(`https:"cm">//api.multimail.dev/read_email?id=${email.id}`, {
      headers: { 'Authorization': `Bearer ${process.env.MULTIMAIL_API_KEY}` },
    });
    const full = await readRes.json() as {
      body_text: string;
      metadata: { linear_issue_id?: string };
    };

    if (!full.metadata?.linear_issue_id) continue;

    await linear.createComment({
      issueId: full.metadata.linear_issue_id,
      body: `Reply from ${email.from}:\n\n> ${full.body_text.slice(0, 500)}`,
    });

    console.log(`Attached reply to Linear issue ${full.metadata.linear_issue_id}`);
  }
}

Use MultiMail to read inbound replies to Linear-triggered notifications and attach them back to the originating issue as comments.


Step by step

1

Install the Linear SDK and configure your MultiMail mailbox

Install the Linear TypeScript SDK. In your MultiMail dashboard, create a mailbox for agent-sent notifications (e.g., `[email protected]`) and set its oversight mode to `gated_send` to hold all outbound emails for review.

bash
npm install @linear/sdk
"cm"># Set environment variables
export LINEAR_API_KEY=lin_api_...
export MULTIMAIL_API_KEY=mm_live_...
2

Register a Linear webhook for issue events

In your Linear workspace settings, create a webhook pointing to your handler endpoint. Subscribe to `Issue` events at minimum. Linear will POST to your URL on every state change, assignment, or label update.

bash
"cm"># Via Linear GraphQL API
curl -X POST https://api.linear.app/graphql \
  -H "Authorization: $LINEAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d &"cm">#039;{
    "query": "mutation { webhookCreate(input: { url: \"https://yourapp.com/webhooks/linear\", teamId: \"YOUR_TEAM_ID\", resourceTypes: [\"Issue\", \"Cycle\"] }) { success webhook { id } } }"
  }&"cm">#039;
3

Write the webhook handler

Handle the Linear webhook payload, fetch any additional context from the Linear API, and call `POST /send_email` on MultiMail. Include the Linear issue ID in `metadata` so you can correlate approval decisions back to issues.

bash
const res = await fetch(&"cm">#039;https://api.multimail.dev/send_email', {
  method: &"cm">#039;POST',
  headers: {
    &"cm">#039;Authorization': `Bearer ${process.env.MULTIMAIL_API_KEY}`,
    &"cm">#039;Content-Type': 'application/json',
  },
  body: JSON.stringify({
    from: &"cm">#039;[email protected]',
    to: [&"cm">#039;[email protected]'],
    subject: `Issue resolved: ${issueTitle}`,
    text: `The reported issue has been marked Done.\n\n${issueUrl}`,
    metadata: { linear_issue_id: issueId },
  }),
});
const { id, status } = await res.json();
// status: &"cm">#039;pending' (gated_send) or 'sent' (autonomous)
4

Handle MultiMail approval webhooks to close the loop

Register a MultiMail webhook for `email.approved` and `email.rejected` events. When an approval decision arrives, extract `metadata.linear_issue_id` and post a comment to the Linear issue confirming whether the notification was sent.

bash
app.post(&"cm">#039;/webhooks/multimail', async (c) => {
  const { event, email } = await c.req.json();
  const issueId = email.metadata?.linear_issue_id;
  if (!issueId) return c.json({ ok: true });

  const comment = event === &"cm">#039;email.approved'
    ? `Notification sent to ${email.to.join(&"cm">#039;, ')}`
    : `Notification rejected — not sent`;

  await linear.createComment({ issueId, body: comment });
  return c.json({ ok: true });
});

Common questions

Does MultiMail integrate directly with Linear or does it require custom code?
MultiMail does not have a native Linear connector. You write a webhook handler that receives Linear events, queries the Linear API for context, and calls the MultiMail REST API. The integration is typically 50–100 lines of TypeScript. There is no Linear-specific SDK on the MultiMail side — the same `POST /send_email` endpoint used for all integrations applies here.
How do I prevent the agent from emailing the same person twice about the same issue?
Include a deduplication key in the `metadata` field (e.g., `linear_issue_id` + `event_type`). Before calling `POST /send_email`, call `GET /check_inbox` or query your own database for prior sends tagged with the same key. MultiMail does not deduplicate automatically — your handler is responsible for checking whether a notification was already proposed or sent.
Can I use `gated_all` for internal stakeholder digests and `autonomous` for read receipts in the same integration?
Yes. Oversight mode is set per mailbox, not per API call. Create two mailboxes — `[email protected]` with `gated_all` and `[email protected]` with `autonomous` — and route calls to the appropriate `from` address based on the email's stakes.
How do I attach the approval decision back to the Linear issue without polling?
Register a MultiMail webhook for `email.approved` and `email.rejected` events in your dashboard. When MultiMail fires the webhook, your handler receives the full email object including the `metadata` you attached at send time. Extract `metadata.linear_issue_id` and call `linear.createComment()` to post the outcome to the issue.
Does MultiMail handle unsubscribes for Linear-triggered notifications?
Yes. MultiMail automatically inserts a List-Unsubscribe header and honors unsubscribe requests as required by CAN-SPAM. If a recipient unsubscribes, subsequent calls to `send_email` targeting that address will be blocked with a `recipient_unsubscribed` error. Your handler should surface this back to Linear — e.g., tag the issue or update a custom field — so the agent doesn't keep proposing emails to opted-out addresses.
Can I run this integration in a Linear-native automation or does it need an external server?
Linear automations cannot make arbitrary HTTP requests — they trigger predefined actions within Linear. You need an external server or serverless function (Cloudflare Workers, AWS Lambda, a Hono app) to receive the webhook, query the Linear GraphQL API, and call MultiMail. The handler is stateless and can run in any environment that supports outbound HTTPS.

Explore more

The only agent email with a verifiable sender

Email infrastructure built for AI agents. Verifiable identity, graduated oversight, and a 38-tool MCP server. Formally verified in Lean 4.