paperclipai/paperclip · error

Email attachment unavailable

Error message

Email attachment unavailable

What it means

When fetching an email attachment from a remote URL, emailChannelService uses guardedRemoteHttpFetch (SSRF/permission-guarded) with a 25s timeout. If the response is not ok (non-2xx) or has no body, it throws 'Email attachment unavailable'.

Solutions

  1. Check the attachment URL in a browser/curl to confirm it serves the file
  2. Re-request or re-upload the attachment to get a fresh signed URL
  3. Confirm the host is reachable from the Paperclip server (firewall/egress rules)
  4. If the URL requires auth, attach it via inline content instead of a link
Defensive patterns

Strategy: try-catch

Validate before calling

const head = await fetch(url, { method: "HEAD" });
if (!head.ok) throw new Error(`Attachment URL unreachable: HTTP ${head.status}`);

Try / catch

try {
  await emailAttachmentFetcher.fetch(url);
} catch (e) {
  if (e instanceof Error && e.message === "Email attachment unavailable") {
    notifySender("attachment link could not be downloaded");
  } else throw e;
}

Prevention

When it happens

Trigger: Attachment URL returns 404/403/500; the URL responds with an empty body or a response type without a streamable body; guardedRemoteHttpFetch blocks the URL (that path instead yields the badRequest 'Email attachment URL is not permitted').

Common situations: Sender links to a signed URL that expired; attachment host requires auth or cookies; CDN returns 403 to datacenter IPs; link rot on old emails.

Understand the failure class

Background: "API error: {status}" and "HTTP 401/403/404/429/5xx" errors: non-2xx HTTP responses explained — this error's family across 27 libraries.

Related errors


AI-assisted analysis of paperclipai/paperclip@3f1d897a7c (2026-09-18). Data as JSON: /api/errors/640cf62868738d66. Report an issue: GitHub.

Appendix: source

Thrown at server/src/services/email-channels.ts:1010

        omitted.push(attachment.filename ?? "attachment");
        continue;
      }
      const locator = await api.getAttachment(
        endpoint.botExternalId!,
        message.message_id,
        attachment.attachment_id,
      );
      const url = new URL(locator.download_url);
      if (url.protocol !== "https:" || locator.size > MAX_ATTACHMENT_BYTES)
        throw badRequest("Email attachment download is not permitted");
      const { guardedRemoteHttpFetch } = await import("./remote-http-fetch.js");
      const response = await guardedRemoteHttpFetch(
        url,
        { signal: AbortSignal.timeout(25_000) },
        { error: () => badRequest("Email attachment URL is not permitted") },
      );
      if (!response.ok || !response.body)
        throw new Error("Email attachment unavailable");
      const reader = response.body.getReader();
      const chunks: Buffer[] = [];
      let size = 0;
      try {
        for (;;) {
          const chunk = await reader.read();
          if (chunk.done) break;
          size += chunk.value.length;
          if (size > MAX_ATTACHMENT_BYTES)
            throw badRequest("Email attachment exceeds the size limit");
          chunks.push(Buffer.from(chunk.value));
        }
      } finally {
        await reader.cancel();
      }
      const stored = await options.storage.putFile({
        companyId: endpoint.companyId,
        namespace: `issues/${conversation.issueId}`,

View on GitHub (pinned to 3f1d897a7c)