Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions sections/inbound-email/dotnet.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
35 changes: 35 additions & 0 deletions sections/inbound-email/go.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
46 changes: 46 additions & 0 deletions sections/inbound-email/phoenix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
38 changes: 37 additions & 1 deletion sections/inbound-email/spring.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,47 @@ The response shape:
```json
{
"status": "created",
"outcome": "CREATED_NEW",
"outcome": "created_new",
"ticketId": 7,
"replyId": null,
"pendingAttachmentDownloads": []
}
```

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<AttachmentDownloader.Result> 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`.
38 changes: 38 additions & 0 deletions sections/inbound-email/symfony.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down