postal icon indicating copy to clipboard operation
postal copied to clipboard

feat: add support for Message-ID handling and idempotency in send API

Open max-kuklin opened this issue 2 months ago • 4 comments

Add Idempotency Support for Email Sending APIs

Summary

We send a large volume of emails through the Postal API with instances installed on remote ISPs. Network hiccups are inevitable in this setup, causing API requests to timeout or fail intermittently. Without idempotency, retrying these failed requests results in duplicate emails being sent to recipients.

This PR implements idempotency for the API endpoints (/api/v1/send/message and /api/v1/send/raw) using RFC 5322 Message-ID headers. Clients can now safely retry failed requests without sending duplicate emails, using client-defined Message-IDs for precise control over deduplication.

Changes

API Enhancements

/api/v1/send/message endpoint:

  • New optional message_ids parameter accepts a hash mapping recipient email addresses to custom Message-IDs
  • Enables per-recipient idempotency control
  • Returns message_id and existing flag for each recipient in the response

/api/v1/send/raw endpoint:

  • Automatically extracts Message-ID from raw email headers
  • Performs duplicate detection per recipient
  • Returns message_id and existing flag for each recipient in the response
  • No API parameter changes required

Response Format

When Message-IDs are provided or detected:

{
  "status": "success",
  "data": {
    "messages": {
      "[email protected]": {
        "id": 123,
        "token": "abc123",
        "message_id": "<[email protected]>",
        "existing": false
      }
    }
  }
}

Validation

  • Message-IDs must follow RFC 5322 format: local-part@domain (angle brackets optional in API but stripped for storage)
  • Invalid Message-IDs return InvalidMessageID error with clear format requirements
  • Validation occurs at model level before database interaction

Implementation Details

  • Model: OutgoingMessagePrototype enhanced with message_ids attribute, validation logic, and per-recipient duplicate detection
  • Controller: SendController updated to accept message_ids parameter and extract Message-ID from raw email headers
  • Database: Uses existing message_id column (varchar 255) with partial index on first 8 characters
  • Duplicate Detection: Queries existing messages by Message-ID and recipient before creating new records
  • No Migration Required: Leverages existing database schema

Testing

Comprehensive test suite with 46 passing tests (0 failures):

  • 13 tests for /message endpoint idempotency (basic flow, per-recipient IDs, duplicates, partial duplicates, angle brackets, non-hash params)
  • 10 tests for /raw endpoint idempotency (Message-ID extraction, duplicates, partial duplicates, angle brackets)
  • 3 tests for Message-ID format validation (invalid format, missing local part, spaces)
  • 20 existing tests for backward compatibility

Usage Examples

/message endpoint with idempotency:

POST /api/v1/send/message
{
  "to": ["[email protected]", "[email protected]"],
  "from": "[email protected]",
  "subject": "Test",
  "plain_body": "Hello",
  "message_ids": {
    "[email protected]": "[email protected]",
    "[email protected]": "[email protected]"
  }
}

/raw endpoint (automatic Message-ID extraction):

POST /api/v1/send/raw
{
  "mail_from": "[email protected]",
  "rcpt_to": ["[email protected]"],
  "data": "Message-ID: <[email protected]>\r\nFrom: [email protected]\r\n..."
}

Benefits

  1. Prevents Duplicate Sends: Clients can safely retry failed requests without sending duplicate emails
  2. Per-Recipient Control: Different Message-IDs for each recipient in multi-recipient messages
  3. RFC 5322 Compliant: Uses standard email Message-ID format
  4. Backward Compatible: Existing clients work unchanged; idempotency is optional
  5. No Database Changes: Uses existing schema with efficient indexed queries

Backward Compatibility

100% Backward Compatible

  • message_ids parameter is optional for /message endpoint
  • Existing requests without Message-IDs work exactly as before
  • Response format extended but doesn't break existing parsers
  • No breaking changes to any endpoint

max-kuklin avatar Nov 18 '25 02:11 max-kuklin

sounds exciting, thanks for your efforts!

willpower232 avatar Nov 18 '25 11:11 willpower232

Hi @adamcooke, do you think this has a chance to be included in the next release? If I can help you in any way please let me know 🙏

max-kuklin avatar Nov 25 '25 14:11 max-kuklin

EDIT: i made some incorrect suggestions for code changes here, mixed up some commits. View history for details.

quaaantumdev avatar Dec 10 '25 21:12 quaaantumdev

@quaaantumdev it sounds like you are referencing a different idempotency design pattern (likely Stripe-style idempotency keys with database locking) rather than Message-ID based deduplication approach. The fixes you suggest would require implementing an entirely new architecture that doesn't currently exist in this PR (including the files you propose to fix).

max-kuklin avatar Dec 11 '25 15:12 max-kuklin

@max-kuklin sorry max, i was just in desprite search for idempotency with postal and must have mixed up this pull request with some other commit, solving the same issue in a different way. I actually prefer your solution, would be great to see this RP merged for a future release.

quaaantumdev avatar Dec 19 '25 13:12 quaaantumdev