From 49a1845874d38c97efcaab98a79d04c2e56555d6 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 07:50:23 -0400 Subject: [PATCH] docs(inbound-email): document AttachmentDownloader across greenfield frameworks Every greenfield framework now ships a reference AttachmentDownloader for persisting the provider-hosted URLs in pending_attachment_downloads (Mailgun's larger files). Before this round host maintainers had to write their own download + persist logic. Each of the 5 framework pages gains a "Downloading provider-hosted attachments" section with: - Ready-to-paste snippet constructing the downloader with the reference LocalFile storage backend. - Size-cap + Mailgun basic-auth wiring. - downloadAll partial-failure semantics. - Typed too-large error per framework. - Path-traversal sanitization guarantees. - Escape hatch: how to swap in S3 / GCS / Azure by implementing the storage interface. Drive-by: fix Spring page's response example (outcome was uppercased "CREATED_NEW"; actual response is lowercased "created_new" via Enum.name().toLowerCase()). --- sections/inbound-email/dotnet.md | 32 +++++++++++++++++++++ sections/inbound-email/go.md | 35 +++++++++++++++++++++++ sections/inbound-email/phoenix.md | 46 +++++++++++++++++++++++++++++++ sections/inbound-email/spring.md | 38 ++++++++++++++++++++++++- sections/inbound-email/symfony.md | 38 +++++++++++++++++++++++++ 5 files changed, 188 insertions(+), 1 deletion(-) diff --git a/sections/inbound-email/dotnet.md b/sections/inbound-email/dotnet.md index 9a1fed4..660cc82 100644 --- a/sections/inbound-email/dotnet.md +++ b/sections/inbound-email/dotnet.md @@ -82,3 +82,35 @@ The response shape: ``` Provider-hosted attachments (Mailgun's larger files, for example) appear in `pendingAttachmentDownloads` so a background worker can fetch and persist them out-of-band. + +### Downloading provider-hosted attachments + +The bundle ships `AttachmentDownloader` for persisting the URLs in `pendingAttachmentDownloads`. Run it after `InboundEmailService.ProcessAsync` returns — typically from a background job queue so the webhook response is sent back to the provider without waiting for the download. + +```csharp +using Escalated.Services.Email.Inbound; + +var storage = new LocalFileAttachmentStorage("/var/escalated/attachments"); + +var downloader = new AttachmentDownloader( + httpClient: new HttpClient(), + storage: storage, + db: dbContext, + logger: logger, + options: new AttachmentDownloaderOptions + { + MaxBytes = 25 * 1024 * 1024, // 25 MB size cap + BasicAuth = new BasicAuth("api", mailgunApiKey), // required for Mailgun + }); + +var results = await downloader.DownloadAllAsync( + result.PendingAttachmentDownloads, + ticketId: result.TicketId.Value, + replyId: result.ReplyId); +``` + +`DownloadAllAsync` continues past per-attachment failures so a single bad URL doesn't block the rest. Each input gets an `AttachmentDownloadResult` with either `Persisted` (the new `Attachment` row) or `Error` (the exception) set. + +Over-sized attachments raise `AttachmentTooLargeException` — the partial body isn't persisted. Crafted filenames like `../../etc/passwd` are neutralized via `Path.GetFileName` before they hit the storage backend. + +Host apps with durable cloud storage (S3, Azure Blob, GCS) implement `IAttachmentStorage` themselves and pass it to `AttachmentDownloader` in place of the reference `LocalFileAttachmentStorage`. diff --git a/sections/inbound-email/go.md b/sections/inbound-email/go.md index 51a4135..2707a2f 100644 --- a/sections/inbound-email/go.md +++ b/sections/inbound-email/go.md @@ -86,3 +86,38 @@ The response shape: ``` Provider-hosted attachments (Mailgun's larger files, for example) appear in `pending_attachment_downloads` so a background worker can fetch and persist them out-of-band. + +### Downloading provider-hosted attachments + +The module ships `email.AttachmentDownloader` for persisting the URLs in `pending_attachment_downloads`. Run it from a goroutine (or a queue worker) after `InboundEmailService.Process` returns, so the webhook response goes back to the provider without waiting for the download. + +```go +import "github.com/escalated-dev/escalated-go/services/email" + +storage, err := email.NewLocalFileStorage("/var/escalated/attachments") +if err != nil { + log.Fatal(err) +} + +downloader := email.NewAttachmentDownloader( + email.DownloadConfig{ + MaxBytes: 25 * 1024 * 1024, // 25 MB size cap + BasicAuth: &email.BasicAuth{Username: "api", Password: mailgunAPIKey}, + }, + storage, + store, // your store.Store — already implements CreateAttachment +) + +results, errs := downloader.DownloadAll(ctx, result.PendingAttachmentDownloads, result.TicketID, nil) +for i, err := range errs { + if err != nil { + log.Printf("download %d failed: %v", i, err) + } +} +``` + +`DownloadAll` continues past per-attachment failures so a single bad URL doesn't block the rest. Parallel slices report each input's outcome: a non-nil `*models.Attachment` on success and a non-nil `error` on failure. + +Over-sized attachments return `email.ErrAttachmentTooLarge` (check with `errors.Is`) — the partial body isn't persisted. Crafted filenames like `../../etc/passwd` are neutralized via `filepath.Base` before they hit the storage backend. + +Host apps with durable cloud storage (S3, GCS, Azure Blob) implement `email.AttachmentStorage` themselves and pass it to `NewAttachmentDownloader` in place of the reference `LocalFileStorage`. diff --git a/sections/inbound-email/phoenix.md b/sections/inbound-email/phoenix.md index 0b73d69..fbb074d 100644 --- a/sections/inbound-email/phoenix.md +++ b/sections/inbound-email/phoenix.md @@ -85,3 +85,49 @@ The response shape: ``` Provider-hosted attachments (Mailgun's larger files, for example) appear in `pending_attachment_downloads` so a background worker can fetch and persist them out-of-band. + +### Downloading provider-hosted attachments + +The library ships `Escalated.Services.Email.Inbound.AttachmentDownloader` for persisting the URLs in `:pending_attachment_downloads`. Run it from a `Task.async` or a dedicated Oban/Exq job so the webhook response goes back to the provider without waiting for the download. + +```elixir +alias Escalated.Services.Email.Inbound.AttachmentDownloader +alias Escalated.Services.Email.Inbound.LocalFileStorage + +storage = LocalFileStorage.new("/var/escalated/attachments") + +writer = %{ + create_attachment: fn attrs -> + %Escalated.Schemas.Attachment{} + |> Escalated.Schemas.Attachment.changeset(attrs) + |> MyApp.Repo.insert() + end +} + +options = %{ + max_bytes: 25 * 1024 * 1024, # 25 MB size cap + basic_auth: {"api", System.fetch_env!("MAILGUN_API_KEY")} # required for Mailgun +} + +results = + AttachmentDownloader.download_all( + result.pending_attachment_downloads, + result.ticket_id, + result.reply_id, + storage, + writer, + options + ) +``` + +`download_all/6` continues past per-attachment failures so a single bad URL doesn't block the rest. Each input gets a `%{pending, persisted, error}` map — `:persisted` is the inserted `Attachment` struct on success, `:error` carries the reason on failure. + +Over-sized attachments return `{:error, {:too_large, actual_bytes, max_bytes}}` — the partial body isn't persisted. Crafted filenames like `../../etc/passwd` are neutralized via `Path.basename/1` before they hit the storage backend. + +The default HTTP client is `:httpc` from the Erlang stdlib (no external dep). Host apps using Finch / HTTPoison / Req can pass `:http_client` to `options`: + +```elixir +options = Map.put(options, :http_client, {MyApp.FinchClient, :get}) +``` + +Host apps with durable cloud storage (S3, GCS, Azure Blob) build their own storage function-map with `put: fn filename, content, content_type -> ...` and pass it in place of `LocalFileStorage.new/1`. diff --git a/sections/inbound-email/spring.md b/sections/inbound-email/spring.md index 7597af0..a3f2361 100644 --- a/sections/inbound-email/spring.md +++ b/sections/inbound-email/spring.md @@ -70,7 +70,7 @@ The response shape: ```json { "status": "created", - "outcome": "CREATED_NEW", + "outcome": "created_new", "ticketId": 7, "replyId": null, "pendingAttachmentDownloads": [] @@ -78,3 +78,39 @@ The response shape: ``` Provider-hosted attachments (Mailgun's larger files, for example) appear in `pendingAttachmentDownloads` so a background worker can fetch and persist them out-of-band. + +### Downloading provider-hosted attachments + +The starter ships `AttachmentDownloader` for persisting the URLs in `pendingAttachmentDownloads`. Run it after `InboundEmailService.process` returns — typically from an `@Async` method or a Spring Batch job so the webhook response is sent back to the provider without waiting for the download. + +```java +import dev.escalated.services.email.inbound.AttachmentDownloader; +import dev.escalated.services.email.inbound.LocalFileAttachmentStorage; + +import java.net.http.HttpClient; +import java.nio.file.Path; + +var storage = new LocalFileAttachmentStorage(Path.of("/var/escalated/attachments")); + +var downloader = new AttachmentDownloader( + HttpClient.newHttpClient(), + storage, + attachmentRepository, + ticketRepository, + replyRepository, + new AttachmentDownloader.Options() + .maxBytes(25L * 1024 * 1024) // 25 MB size cap + .basicAuth("api", mailgunApiKey) // required for Mailgun +); + +List results = downloader.downloadAll( + result.pendingAttachmentDownloads(), + result.ticketId(), + result.replyId()); +``` + +`downloadAll` continues past per-attachment failures so a single bad URL doesn't block the rest. Each input gets a `Result` with either `persisted` (the new `Attachment` row) or `error` (the `Throwable`) set. + +Over-sized attachments raise `AttachmentTooLargeException` — the partial body isn't persisted. Crafted filenames like `../../etc/passwd` are neutralized via `Path.getFileName` before they hit the storage backend. + +Host apps with durable cloud storage (S3, Azure Blob, GCS) implement `AttachmentStorage` themselves and pass it to `AttachmentDownloader` in place of the reference `LocalFileAttachmentStorage`. diff --git a/sections/inbound-email/symfony.md b/sections/inbound-email/symfony.md index 0bcde6f..beb34d2 100644 --- a/sections/inbound-email/symfony.md +++ b/sections/inbound-email/symfony.md @@ -78,6 +78,44 @@ The response shape: Provider-hosted attachments (Mailgun's larger files, for example) appear in `pending_attachment_downloads` so a background worker can fetch and persist them out-of-band. +### Downloading provider-hosted attachments + +The bundle ships `AttachmentDownloader` for persisting those URLs. Run it after `InboundEmailService::process()` returns — typically dispatched through Symfony Messenger so the webhook response goes back to the provider without waiting for the download. + +```php +use Escalated\Symfony\Mail\Inbound\AttachmentDownloader; +use Escalated\Symfony\Mail\Inbound\AttachmentDownloaderOptions; +use Escalated\Symfony\Mail\Inbound\BasicAuth; +use Escalated\Symfony\Mail\Inbound\CurlAttachmentHttpClient; +use Escalated\Symfony\Mail\Inbound\LocalFileAttachmentStorage; + +$storage = new LocalFileAttachmentStorage('/var/escalated/attachments'); + +$downloader = new AttachmentDownloader( + httpClient: new CurlAttachmentHttpClient(), + storage: $storage, + em: $entityManager, + options: new AttachmentDownloaderOptions( + maxBytes: 25 * 1024 * 1024, // 25 MB size cap + basicAuth: new BasicAuth('api', $_ENV['MAILGUN_API_KEY']), + ), +); + +$results = $downloader->downloadAll( + $result->pendingAttachmentDownloads, + ticketId: $result->ticketId, + replyId: $result->replyId, +); +``` + +`downloadAll()` continues past per-attachment failures so a single bad URL doesn't block the rest. Each input gets an `AttachmentDownloadResult` with either `persisted` (the new `Attachment` entity) or `error` (the `Throwable`) set. + +Over-sized attachments throw `AttachmentTooLargeException` — the partial body isn't persisted. Crafted filenames like `../../etc/passwd` are neutralized via `basename()` before they hit the storage backend. + +The default HTTP client uses cURL (no extra Composer dep). Host apps using `symfony/http-client`, Guzzle, etc. can implement `AttachmentHttpClientInterface` with a thin adapter and pass it instead. + +Host apps with durable cloud storage (S3, Azure Blob, GCS) implement `AttachmentStorageInterface` themselves and pass it to `AttachmentDownloader` in place of the reference `LocalFileAttachmentStorage`. + ### Adding a custom parser The bundle discovers inbound parsers by the `escalated.inbound_parser` tag. To add a new one, implement `Escalated\Symfony\Mail\Inbound\InboundEmailParser` and autoconfigure: