Skip to content

Conversation

@peterpacket
Copy link

@peterpacket peterpacket commented Apr 2, 2025

Why are you making it?

We use postmark to send our mails, but we are missing a postmark integration for Ghost.

What does it do?

It add postmark as a second mail service provider

Why is this something Ghost users or developers need?

For Ghost users where mailgun is not an option.

Please check your PR against these items:

  • I've read and followed the Contributor Guide
  • I've explained my change
  • I've written an automated test to prove my change works

This a copy of #20530, submitting it again because our main branch got corrupted due to a merge conflict.


Note

Introduces Postmark as an alternative bulk email provider and refactors email sending/analytics to a provider-agnostic setup.

  • New Admin settings UI BulkEmail.tsx replaces Mailgun-only UI; users select bulk_email_provider (postmark or mailgun) and configure postmark_api_token or Mailgun keys
  • Adds settings/fixtures/schema keys: bulk_email_provider, postmark_api_token; exposes postmarkIsConfigured in public config
  • Refactors sending to generic BulkEmailProvider using either @tryghost/postmark-client or MailgunClient based on bulk_email_provider; updates suppression list to use selected client
  • Adds @tryghost/postmark-client package (send, events, suppressions) and @tryghost/email-analytics-provider-postmark; wires into EmailAnalyticsServiceWrapper
  • Updates editor publish flow and preview to check bulkEmailIsConfigured instead of Mailgun-only; adjusts acceptance/unit tests accordingly

Written by Cursor Bugbot for commit 5209ffa. This will update automatically on new commits. Configure here.

danielraffel added a commit to danielraffel/Ghost that referenced this pull request Oct 25, 2025
ref TryGhost#22771

This establishes the foundation for multi-provider email support using
Ghost's existing AdapterManager pattern. The EmailProviderBase class
defines the contract that all email provider adapters must follow,
enabling community-developed providers to be published as npm packages.

- Created EmailProviderBase with required send() method
- Registered email adapter type in AdapterManager
- Added comprehensive tests validating base class contract
@danielraffel
Copy link

danielraffel commented Oct 27, 2025

Hey @peterpacket and @andreascreten reaching out again as I am not sure you saw my comment.

Another way to approach this would be to make the smallest change you can find that moves the cdoebase towards having mailgun bulk mail be an adapter, rather than built-in, ensure its well tested and easy to reason about and then raise that as a PR. We can get that merged, and then do the next on, until building an adapter is entirely possible. That way we can slowly build up confidence working together.

Doing one big PR without a plan is not going to get us where we need to go. I'm messaging again because I'm very keen to support this change.

Hi @ErisDS I’ve submitted a PR series to add the adapter. Hoping to align on next steps for it with you and begin a plan for the first third-party Amazon SES adapter and admin UX work. Happy to also collaborate with @andreascreten @peterpacket on migrating their Postmark work too if useful. Thanks for any guidance and support you can offer me to help make this happen!

kevinansfield and others added 3 commits November 5, 2025 13:14
no issue

- `tokenProvider.verifyOTC` has an internal dependency on the `otcRef`
being correct meaning we'd never reach the `INVALID_OTC_REF` condition
- nothing changes for user-visible behaviour but this re-order does mean
that logging can be more explicit about the type of error that has
occurred

-----

Co-authored-by: Steve Larson <9larsons@gmail.com>
ref https://linear.app/ghost/issue/BER-2977/

- during a refactor of `SingleUseTokenProvider` the
time-since-first-usage check was extracted to a method but in doing so
the pre-condition check for the token having been used was lost meaning
all magic links had an effective lifetime of `validityAfterFirstUsage`
(10 minutes)
- restored pre-condition check and added missing test that would have
caught this during the refactor
danielraffel added a commit to danielraffel/Ghost that referenced this pull request Nov 6, 2025
ref TryGhost#22771

This establishes the foundation for multi-provider email support using
Ghost's existing AdapterManager pattern. The EmailProviderBase class
defines the contract that all email provider adapters must follow,
enabling community-developed providers to be published as npm packages.

- Created EmailProviderBase with required send() method
- Registered email adapter type in AdapterManager
- Added comprehensive tests validating base class contract
danielraffel added a commit to danielraffel/Ghost that referenced this pull request Nov 6, 2025
fixes TryGhost#22771

PR6 (SES Analytics):
- Fixed partial SQS message deletion: When maxEvents limit is reached mid-message, don't delete the message so next run can process remaining recipients
- Added fullyProcessed flag to track whether all events from a message were processed

PR7 (Email Personalization):
- Fixed hasPersonalization detection: Now ignores required system tokens (list_unsubscribe) that are always present
- Bulk BCC path now actually runs for non-personalized newsletters
- Only sends personalized emails when actual member data is used (name, email, etc)

Technical Details:
1. SQS Message Handling:
   - Only marks messages for deletion if all events processed
   - Partially processed messages remain in queue for next analytics run
   - Prevents losing recipients in bulk sends when hitting maxEvents limit

2. Personalization Detection:
   - Checks for replacements beyond REQUIRED_TOKEN_IDS array
   - list_unsubscribe is always present (required: true in EmailRenderer)
   - Bulk path triggers when only system tokens present
   - Personalized path triggers when name, email, first_name, etc used

Example:
- Newsletter with no %%{name}%%: Uses bulk BCC (1 API call per 50 recipients)
- Newsletter with %%{name}%%: Uses personalized (1 API call per recipient)
Includes admin build orchestration improvements, Portal fixes,
i18n updates, and e2e test infrastructure enhancements.
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

This PR is being reviewed by Cursor Bugbot

Details

Your team is on the Bugbot Free tier. On this plan, Bugbot will review limited PRs each billing cycle for each member of your team.

To receive Bugbot reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.

id: event.MessageID,
type: 'opened',
severity: 'permanent',
recipientEmail: event.Recipient,
Copy link

Choose a reason for hiding this comment

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

Bug: Potential null reference error in normalizeEvent

The normalizeEvent method calls event.Tag.split('|')[1] without checking if event.Tag exists or is a string. If Postmark returns an event without a Tag field or with a null/undefined Tag, this will throw a runtime error. The nullish coalescing operator only handles the case where the split result doesn't have a second element, not when Tag itself is missing.

Fix in Cursor Fix in Web

onEditingChange={handleEditingChange}
onSave={async () => {
handleSave();
}}
Copy link

Choose a reason for hiding this comment

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

Bug: Missing Mailgun region default value handling

The new BulkEmail component removes the special handling that existed in the old Mailgun component for setting the default Mailgun region. When a user saves Mailgun settings without explicitly selecting a region, the mailgun_base_url remains null because the Select component doesn't trigger updateSetting if the user doesn't interact with it. This breaks Mailgun configuration for new setups where users don't explicitly select a region.

Fix in Cursor Fix in Web

logging.error(error);
throw error;
}
}
Copy link

Choose a reason for hiding this comment

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

Bug: Postmark analytics ignores event filter, only tracks opens

The fetchEvents method receives postmarkOptions (which includes the event filter for 'delivered OR opened OR failed OR unsubscribed OR complained') but completely ignores it. The method only calls getMessageOpens() which fetches only open events. Additionally, normalizeEvent hardcodes type: 'opened' for all events. This means delivery tracking, bounce tracking, unsubscribe tracking, and spam complaint tracking will not work with Postmark, causing Ghost to have incomplete email analytics data.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

This PR is being reviewed by Cursor Bugbot

Details

Your team is on the Bugbot Free tier. On this plan, Bugbot will review limited PRs each billing cycle for each member of your team.

To receive Bugbot reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.

logging.error(error);
metrics.metric('postmark-send-mail', {
value: Date.now() - startTime,
statusCode: error.code
Copy link

Choose a reason for hiding this comment

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

startTime may be undefined in error metrics

In the send method's catch block, Date.now() - startTime is used to calculate the metric value. However, startTime is only assigned at line 95, after building emailMessages. If an error occurs during the message building phase (lines 51-93), startTime will be undefined and the metric value will be NaN.

Fix in Cursor Fix in Web

Object.keys(recipientData[recipient]).forEach((key) => {
messageData.HtmlBody = messageData.HtmlBody.replaceAll(`%recipient.${key}%`, recipientData[recipient][key]);
messageData.TextBody = messageData.TextBody.replaceAll(`%recipient.${key}%`, recipientData[recipient][key]);
messageData.Subject = messageData.Subject.replaceAll(`%recipient.${key}%`, recipientData[recipient][key]);
Copy link

Choose a reason for hiding this comment

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

replaceAll on potentially null HtmlBody or TextBody crashes

The send method calls .replaceAll() on messageData.HtmlBody, messageData.TextBody, and messageData.Subject without null checks. These values come from messageContent.html and messageContent.plaintext. If the message content is null or undefined (e.g., if an email has no HTML or plaintext version), calling .replaceAll() will throw a TypeError, crashing email sending.

Fix in Cursor Fix in Web

const section = page.getByTestId('bulk-email');

await expect(section.getByText('Mailgun is not set up')).toHaveCount(1);
await expect(section.getByText('Email provider is not set up')).toHaveCount(1);
Copy link

Choose a reason for hiding this comment

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

Test expects wrong success text after UI change

The test asserts that the text 'Mailgun is set up' is visible after saving, but the new BulkEmail.tsx component displays 'The email provider is set up' instead. This test will fail because the expected text doesn't match the actual UI output.

Fix in Cursor Fix in Web

Added 'string-width', 'strip-ansi', and 'wrap-ansi' packages to dependencies in package.json. These may be required for improved string formatting or terminal output handling.
if (e.error && e.messageData) {
const {error, messageData} = e;

// REF: possible mailgun errors https://documentation.mailgun.com/en/latest/api-intro.html#status-codes
Copy link

Choose a reason for hiding this comment

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

BulkEmailProvider crashes when PostmarkClient returns null

When PostmarkClient.send() is called but Postmark is not configured (no API token), it returns null at line 38-39 of PostmarkClient.js. The BulkEmailProvider.send() method then attempts to access response.id.trim() on this null value, causing a TypeError: Cannot read properties of null. This can occur if a user selects Postmark as provider but doesn't configure the API token.

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community [triage] Community features and bugs migration [pull request] Includes migration for review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants