{"record":{"id":"640cf62868738d66","repo":"paperclipai/paperclip","slug":"email-attachment-unavailable","errorCode":null,"errorMessage":"Email attachment unavailable","messagePattern":"Email attachment unavailable","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"server/src/services/email-channels.ts","lineNumber":1010,"sourceCode":"        omitted.push(attachment.filename ?? \"attachment\");\n        continue;\n      }\n      const locator = await api.getAttachment(\n        endpoint.botExternalId!,\n        message.message_id,\n        attachment.attachment_id,\n      );\n      const url = new URL(locator.download_url);\n      if (url.protocol !== \"https:\" || locator.size > MAX_ATTACHMENT_BYTES)\n        throw badRequest(\"Email attachment download is not permitted\");\n      const { guardedRemoteHttpFetch } = await import(\"./remote-http-fetch.js\");\n      const response = await guardedRemoteHttpFetch(\n        url,\n        { signal: AbortSignal.timeout(25_000) },\n        { error: () => badRequest(\"Email attachment URL is not permitted\") },\n      );\n      if (!response.ok || !response.body)\n        throw new Error(\"Email attachment unavailable\");\n      const reader = response.body.getReader();\n      const chunks: Buffer[] = [];\n      let size = 0;\n      try {\n        for (;;) {\n          const chunk = await reader.read();\n          if (chunk.done) break;\n          size += chunk.value.length;\n          if (size > MAX_ATTACHMENT_BYTES)\n            throw badRequest(\"Email attachment exceeds the size limit\");\n          chunks.push(Buffer.from(chunk.value));\n        }\n      } finally {\n        await reader.cancel();\n      }\n      const stored = await options.storage.putFile({\n        companyId: endpoint.companyId,\n        namespace: `issues/${conversation.issueId}`,","sourceCodeStart":992,"sourceCodeEnd":1028,"githubUrl":"https://github.com/paperclipai/paperclip/blob/3f1d897a7c018d76563a21c6e39c3c9b03933622/server/src/services/email-channels.ts#L992-L1028","documentation":"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'.","triggerScenarios":"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').","commonSituations":"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.","solutions":["Check the attachment URL in a browser/curl to confirm it serves the file","Re-request or re-upload the attachment to get a fresh signed URL","Confirm the host is reachable from the Paperclip server (firewall/egress rules)","If the URL requires auth, attach it via inline content instead of a link"],"exampleFix":null,"handlingStrategy":"try-catch","validationCode":"const head = await fetch(url, { method: \"HEAD\" });\nif (!head.ok) throw new Error(`Attachment URL unreachable: HTTP ${head.status}`);","typeGuard":null,"tryCatchPattern":"try {\n  await emailAttachmentFetcher.fetch(url);\n} catch (e) {\n  if (e instanceof Error && e.message === \"Email attachment unavailable\") {\n    notifySender(\"attachment link could not be downloaded\");\n  } else throw e;\n}","preventionTips":["Prefer durable attachment storage over expiring signed links","Validate URLs return 2xx before relying on them downstream","Avoid hosts that block datacenter IPs or require cookies for attachments","Set a retry with backoff for transient 5xx before giving up"],"tags":["network","http","email"],"backgroundTag":"http-error-response","analyzedSha":"3f1d897a7c018d76563a21c6e39c3c9b03933622","analyzedAt":"2026-09-18T08:03:59.046Z","contentChangedAt":"2026-09-18T08:03:59.046Z","schemaVersion":2},"datasetVersion":"2026-09-22T06:17:15.046Z"}