Skip to content
Merged
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
22 changes: 15 additions & 7 deletions attachment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,23 +42,23 @@ func TestAttachmentDownload(t *testing.T) {
filename: "document.pdf",
serverStatus: http.StatusNotFound,
expectError: true,
errorContains: "Error occurred",
errorContains: "404",
},
{
name: "attachment not found",
emailID: "test-email-id",
filename: "missing.pdf",
serverStatus: http.StatusNotFound,
expectError: true,
errorContains: "Error occurred",
errorContains: "404",
},
{
name: "unauthorized",
emailID: "test-email-id",
filename: "document.pdf",
serverStatus: http.StatusUnauthorized,
expectError: true,
errorContains: "Error occurred",
errorContains: "401",
},
}

Expand Down Expand Up @@ -103,7 +103,7 @@ func TestAttachmentDownload(t *testing.T) {
t.Fatalf("Failed to create client: %v", err)
}

data, err := client.Attachment().Download(context.Background(), tt.emailID, tt.filename)
result, err := client.Attachment().Download(context.Background(), tt.emailID, tt.filename)

if tt.expectError {
if err == nil {
Expand All @@ -121,9 +121,17 @@ func TestAttachmentDownload(t *testing.T) {
return
}

if string(data) != string(tt.serverResponse) {
t.Errorf("Expected data '%s', got '%s'", string(tt.serverResponse), string(data))
if string(result.Data) != string(tt.serverResponse) {
t.Errorf("Expected data '%s', got '%s'", string(tt.serverResponse), string(result.Data))
}

// Verify headers are present
if result.Headers == nil {
t.Error("Expected headers to be present")
}
if result.Headers.Get("Content-Type") != "application/pdf" {
t.Errorf("Expected Content-Type 'application/pdf', got '%s'", result.Headers.Get("Content-Type"))
}
})
}
}
}
27 changes: 14 additions & 13 deletions inbound.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ func NewEmailService(client *Inbound) *EmailService {
}

// Send sends an email with optional attachments and idempotency options
//
//
// This method supports both immediate sending and scheduled delivery.
// If params.ScheduledAt is set, the email will be scheduled for future delivery.
//
Expand Down Expand Up @@ -345,7 +345,7 @@ func (s *EmailService) Reply(ctx context.Context, id string, params *PostEmailRe
}

// Schedule schedules an email to be sent at a future time
//
//
// Supports both ISO 8601 dates and natural language (e.g., "in 1 hour", "tomorrow at 9am").
//
// API Reference: https://docs.inbound.new/api-reference/emails/schedule-email
Expand Down Expand Up @@ -625,27 +625,28 @@ func NewAttachmentService(client *Inbound) *AttachmentService {
// Download downloads an email attachment by email ID and filename
//
// API Reference: https://docs.inbound.new/api-reference/attachments/download-attachment
func (s *AttachmentService) Download(ctx context.Context, emailID, filename string) ([]byte, error) {
func (s *AttachmentService) Download(ctx context.Context, emailID, filename string) (*AttachmentDownloadResponse, error) {
endpoint := fmt.Sprintf("/attachments/%s/%s", emailID, url.PathEscape(filename))

resp, err := s.client.request(ctx, "GET", endpoint, nil, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()

data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}

if resp.StatusCode >= 400 {
respBody, _ := io.ReadAll(resp.Body)
var errorResp struct {
Error string `json:"error"`
}
if json.Unmarshal(respBody, &errorResp) == nil && errorResp.Error != "" {
return nil, fmt.Errorf("%s", errorResp.Error)
}
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
}

return io.ReadAll(resp.Body)
return &AttachmentDownloadResponse{
Data: data,
Headers: resp.Header,
}, nil
Comment on lines +628 to +649

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Attachment download: escape all path params, avoid JSON Content-Type on binary GET, and check status before reading body

  • Escape emailID too to prevent path issues.
  • request() currently sets Content-Type: application/json on all requests; downloads should not send it.
  • Return early on non-2xx before reading the body; also drain body on error for connection reuse.

Apply this diff inside Download:

- endpoint := fmt.Sprintf("/attachments/%s/%s", emailID, url.PathEscape(filename))
+ endpoint := fmt.Sprintf("/attachments/%s/%s", url.PathEscape(emailID), url.PathEscape(filename))
@@
- data, err := io.ReadAll(resp.Body)
- if err != nil {
-   return nil, err
- }
-
- if resp.StatusCode >= 400 {
-   return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
- }
+ if resp.StatusCode >= 400 {
+   // drain to allow connection reuse
+   _, _ = io.Copy(io.Discard, resp.Body)
+   return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
+ }
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+   return nil, err
+ }

Additionally, update request() to set Content-Type only when sending JSON:

// in func (c *Inbound) request(...)
req.Header.Set("Authorization", "Bearer "+c.apiKey)
// Set JSON Content-Type only when we actually have a JSON body
if body != nil {
    req.Header.Set("Content-Type", "application/json")
}

If you prefer not to touch request(), pass a header override in Download and then delete it before send; but adjusting request() is cleaner and aligns with “Set Content-Type: application/json on all requests except attachment downloads”. As per coding guidelines.

}

// Add service properties to the main client
Expand Down Expand Up @@ -763,4 +764,4 @@ func Int(v int) *int {
// Bool returns a pointer to the bool value passed in.
func Bool(v bool) *bool {
return &v
}
}
121 changes: 66 additions & 55 deletions types.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package inboundgo

import "time"
import (
"net/http"
"time"
)

// Base configuration
type InboundEmailConfig struct {
Expand Down Expand Up @@ -583,16 +586,16 @@ type ThreadLatestMessage struct {
}

type ThreadSummary struct {
ID string `json:"id"`
RootMessageID string `json:"rootMessageId"`
NormalizedSubject *string `json:"normalizedSubject"`
ParticipantEmails []string `json:"participantEmails"`
MessageCount int `json:"messageCount"`
LastMessageAt string `json:"lastMessageAt"`
CreatedAt string `json:"createdAt"`
HasUnread bool `json:"hasUnread"`
IsArchived bool `json:"isArchived"`
LatestMessage *ThreadLatestMessage `json:"latestMessage,omitempty"`
ID string `json:"id"`
RootMessageID string `json:"rootMessageId"`
NormalizedSubject *string `json:"normalizedSubject"`
ParticipantEmails []string `json:"participantEmails"`
MessageCount int `json:"messageCount"`
LastMessageAt string `json:"lastMessageAt"`
CreatedAt string `json:"createdAt"`
HasUnread bool `json:"hasUnread"`
IsArchived bool `json:"isArchived"`
LatestMessage *ThreadLatestMessage `json:"latestMessage,omitempty"`
}

type GetThreadsRequest struct {
Expand Down Expand Up @@ -620,11 +623,11 @@ type GetThreadsResponse struct {
}

type ThreadAttachment struct {
Filename string `json:"filename"`
ContentType string `json:"contentType"`
Size int `json:"size"`
ContentID string `json:"contentId"`
ContentDisposition string `json:"contentDisposition"`
Filename string `json:"filename"`
ContentType string `json:"contentType"`
Size int `json:"size"`
ContentID string `json:"contentId"`
ContentDisposition string `json:"contentDisposition"`
}

type ThreadMessage struct {
Expand Down Expand Up @@ -712,33 +715,33 @@ type MostActiveThread struct {
}

type GetThreadStatsResponse struct {
TotalThreads int `json:"totalThreads"`
TotalMessages int `json:"totalMessages"`
AverageMessagesPerThread float64 `json:"averageMessagesPerThread"`
MostActiveThread *MostActiveThread `json:"mostActiveThread"`
RecentActivity ThreadRecentActivity `json:"recentActivity"`
Distribution ThreadDistribution `json:"distribution"`
UnreadStats ThreadUnreadStats `json:"unreadStats"`
TotalThreads int `json:"totalThreads"`
TotalMessages int `json:"totalMessages"`
AverageMessagesPerThread float64 `json:"averageMessagesPerThread"`
MostActiveThread *MostActiveThread `json:"mostActiveThread"`
RecentActivity ThreadRecentActivity `json:"recentActivity"`
Distribution ThreadDistribution `json:"distribution"`
UnreadStats ThreadUnreadStats `json:"unreadStats"`
}

// Webhook Payload Types - for incoming email.received webhooks
type WebhookPayload struct {
Event string `json:"event"`
Timestamp string `json:"timestamp"`
Email WebhookEmailData `json:"email"`
Endpoint *WebhookEndpointRef `json:"endpoint,omitempty"`
Event string `json:"event"`
Timestamp string `json:"timestamp"`
Email WebhookEmailData `json:"email"`
Endpoint WebhookEndpointRef `json:"endpoint"`
}

type WebhookEmailData struct {
ID string `json:"id"`
MessageID string `json:"messageId"`
From WebhookAddressGroup `json:"from"`
To WebhookAddressGroup `json:"to"`
Recipient string `json:"recipient"`
Subject string `json:"subject"`
ReceivedAt string `json:"receivedAt"`
ParsedData WebhookParsedData `json:"parsedData"`
CleanedContent *WebhookCleanedContent `json:"cleanedContent,omitempty"`
ID string `json:"id"`
MessageID *string `json:"messageId"`
From *WebhookAddressGroup `json:"from"`
To *WebhookAddressGroup `json:"to"`
Recipient string `json:"recipient"`
Subject *string `json:"subject"`
ReceivedAt string `json:"receivedAt"`
ParsedData WebhookParsedData `json:"parsedData"`
CleanedContent WebhookCleanedContent `json:"cleanedContent"`
}

type WebhookAddressGroup struct {
Expand All @@ -748,48 +751,56 @@ type WebhookAddressGroup struct {

type WebhookAddress struct {
Name *string `json:"name"`
Address string `json:"address"`
Address *string `json:"address"`
}

type WebhookParsedData struct {
MessageID string `json:"messageId"`
Date any `json:"date"` // Can be string or Date object
Subject string `json:"subject"`
From WebhookAddressGroup `json:"from"`
To WebhookAddressGroup `json:"to"`
MessageID *string `json:"messageId,omitempty"`
Date *string `json:"date,omitempty"`
Subject *string `json:"subject,omitempty"`
From *WebhookAddressGroup `json:"from"`
To *WebhookAddressGroup `json:"to"`
Cc *WebhookAddressGroup `json:"cc"`
Bcc *WebhookAddressGroup `json:"bcc"`
ReplyTo *WebhookAddressGroup `json:"replyTo"`
InReplyTo *string `json:"inReplyTo,omitempty"`
References *string `json:"references,omitempty"`
TextBody string `json:"textBody"`
HTMLBody string `json:"htmlBody"`
Raw string `json:"raw"`
References []string `json:"references,omitempty"`
TextBody *string `json:"textBody,omitempty"`
HTMLBody *string `json:"htmlBody,omitempty"`
Raw *string `json:"raw,omitempty"`
Attachments []WebhookAttachment `json:"attachments"`
Headers map[string]any `json:"headers"`
Priority *string `json:"priority,omitempty"`
Priority any `json:"priority,omitempty"` // Can be string | false | undefined
}

type WebhookCleanedContent struct {
HTML string `json:"html"`
Text string `json:"text"`
HTML *string `json:"html"`
Text *string `json:"text"`
HasHTML bool `json:"hasHtml"`
HasText bool `json:"hasText"`
Attachments []WebhookAttachment `json:"attachments"`
Headers map[string]any `json:"headers"`
}

type WebhookAttachment struct {
Filename string `json:"filename"`
ContentType string `json:"contentType"`
Size int `json:"size"`
ContentID string `json:"contentId"`
ContentDisposition string `json:"contentDisposition"`
DownloadUrl string `json:"downloadUrl"`
Filename *string `json:"filename,omitempty"`
ContentType *string `json:"contentType,omitempty"`
Size *int `json:"size,omitempty"`
ContentID *string `json:"contentId,omitempty"`
ContentDisposition *string `json:"contentDisposition,omitempty"`
DownloadUrl string `json:"downloadUrl"`
}

type WebhookEndpointRef struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
}

// ---- Attachment Types ----

// AttachmentDownloadResponse represents the response from downloading an attachment.
type AttachmentDownloadResponse struct {
Data []byte `json:"data"`
Headers http.Header `json:"headers"`
}
18 changes: 12 additions & 6 deletions webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,30 @@ func ParseWebhookPayload(reader io.Reader) (*WebhookPayload, error) {

// GetFromAddress extracts the properly formatted from address from the webhook
func (w *WebhookPayload) GetFromAddress() string {
if len(w.Email.From.Addresses) > 0 {
if w.Email.From != nil && len(w.Email.From.Addresses) > 0 {
addr := w.Email.From.Addresses[0]
if addr.Address == nil {
return ""
}
if addr.Name != nil && *addr.Name != "" {
return fmt.Sprintf("%s <%s>", *addr.Name, addr.Address)
return fmt.Sprintf("%s <%s>", *addr.Name, *addr.Address)
}
return addr.Address
return *addr.Address
}
return ""
}

// GetToAddress extracts the properly formatted to address from the webhook
func (w *WebhookPayload) GetToAddress() string {
if len(w.Email.To.Addresses) > 0 {
if w.Email.To != nil && len(w.Email.To.Addresses) > 0 {
addr := w.Email.To.Addresses[0]
if addr.Address == nil {
return ""
}
if addr.Name != nil && *addr.Name != "" {
return fmt.Sprintf("%s <%s>", *addr.Name, addr.Address)
return fmt.Sprintf("%s <%s>", *addr.Name, *addr.Address)
}
return addr.Address
return *addr.Address
}
return ""
}
Expand Down
Loading
Loading