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`.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
npm install @linear/sdk
"cm"># Set environment variables
export LINEAR_API_KEY=lin_api_...
export MULTIMAIL_API_KEY=mm_live_...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.
"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;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.
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)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.
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 });
});Email infrastructure built for AI agents. Verifiable identity, graduated oversight, and a 38-tool MCP server. Formally verified in Lean 4.