Skip to content

Replaced welcome email send with "automation run" rails#27364

Open
EvanHahn wants to merge 9 commits intomainfrom
NY-1190
Open

Replaced welcome email send with "automation run" rails#27364
EvanHahn wants to merge 9 commits intomainfrom
NY-1190

Conversation

@EvanHahn
Copy link
Copy Markdown
Contributor

@EvanHahn EvanHahn commented Apr 12, 2026

closes https://linear.app/ghost/issue/NY-1190

I recommend reviewing this commit-by-commit.

This change should have no user impact. It replaces the current welcome email system with one powered by the new automations rails.

We've discussed the design for this change at some length. I hope this is a straightforward implementation of that design. Here's the prompt I gave to a coding agent:

📝 LLM prompt

I'm working on migrating member welcome emails to our new automations data model. See the attached design document below for background.

I've already created the relevant tables/models for this, but most things are still running with the old logic. I want to run member welcome emails on the new data model.

This change should make two significant changes:

  1. When a member signs up…
    1. Dispatch a MemberCreatedEvent. This code already exists and doesn't need to be added.
    2. Add a new listener for MemberCreatedEvent. You should decide where this listener goes, but it should be set up on app boot (directly or indirectly); consider adding it to an existing part of the system that's already listening for domain events. Create a new WelcomeEmailAutomationRun and save it to the database.
      • In core/server/services/members/members-api/repositories/member-repository.js, remove _Outbox interactions.
      • Fields:
        • welcomeEmailAutomationId: either the free or paid automation, depending on how the user signed up
        • memberId: the new/updated member's ID
        • nextWelcomeEmailAutomatedEmailId: the first welcome email's ID. You can assume it's the only one where the automation ID matches
        • readyAt: right now (we may change this in a followup patch)
        • stepStartedAt: null
        • stepAttempts: 0
        • exitReason: null
    3. Start an automations poll (see below).
  2. Create a function that does an automations poll. Ideally, it should be stateless, but if state is needed, it can be part of a class service that's initialized on startup. When an automations poll executes…
    1. In a single transaction…
      1. Select all WelcomeEmailAutomationRuns that we should start (see design_document below for a sketch of a database query, which should use Knex/Bookshelf as much as possible, not raw SQL). Also join the WelcomeEmailAutomation and WelcomeEmailAutomatedEmail.
      2. Update each of these runs
        • stepStartedAt should be "now"
        • stepAttempts should be increased by 1
      3. Ideally, you'd be able to do the SELECT and UPDATE in a single database operation. If this is possible (make sure this works with SQLite and MySQL), do that. If not, use a transaction.
    2. For each WelcomeEmailAutomationRun
      1. If stepAttempts is larger than MAX_ATTEMPTS (which should be hard-coded to 10, but the hard-coded value could change in the future), mark the job totally failed (see below).
      2. Attempt to send the member welcome email. This should replace the existing Outbox handler, which is at least partially defined in core/server/services/outbox/handlers/member-created.js.
        • We shouldn't delete the existing outbox code because it could still have stuff in it. But we will eventually stop running it. (It's possible we'll change the outbox code slightly, but we shouldn't change its functionality.)
      3. If it fails, increment stepAttempts, update readyAt to give some delay, and enqueue another automation poll. (See design_document below for a sketch of a database query, which should use Knex/Bookshelf as much as possible.)
      4. If it succeeds, mark the run as completed. (See design_document below for a sketch of a database query, which should use Knex/Bookshelf as much as possible.)

This patch isn't meant to implement the full design. It's a step in the right direction. This patch should NOT:

  • Handle multiple emails per automation. Assume there's always exactly one
  • Interact with the scheduler in any way
  • Bail out of the automation run if the member no longer exists
  • Bail out of the automation run if the member has unsubscribed
  • Bail out of the automation run if the member's status has changed (e.g., going from free to paid)

This change should have no user impact. It should only change the plumbing of how member welcome emails are sent.

<design_document>
{DESIGN DOCUMENT AS MARKDOWN}
</design_document>

This implemented a great first draft. I made many changes to its output.

Will be used in an upcoming commit.

[I recently patched this to add TypeScript type definitions][0], and
have manually audited this whole dependency (it's very small).

[0]: mk-pmb/getown-js@2c3e325
Will be used in an upcoming commit.
Will be used in an upcoming commit.
Will be used in an upcoming commit.
Nothing reads this yet. Wait for an upcoming commit.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 12, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f7d27fd2-5f2d-4f3d-be69-4dc362e97c1f

📥 Commits

Reviewing files that changed from the base of the PR and between f5ac912 and 835508a.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (1)
  • ghost/core/package.json
✅ Files skipped from review due to trivial changes (1)
  • ghost/core/package.json

Walkthrough

This PR replaces outbox-based welcome email scheduling with a polling-based automation system. It adds a WelcomeEmailAutomationsService (with init(domainEvents)), a StartAutomationsPollEvent, a oneAtATime helper, and a poll worker that processes WelcomeEmailAutomationRun records. Member repository and Members API wiring now create WelcomeEmailAutomationRun entries instead of Outbox events. Boot now initializes the new service (while leaving outbox initialization present with a deprecation TODO). Tests were added/updated to exercise the new automation runs and poll behavior.

Possibly related PRs

Suggested reviewers

  • cmraible
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: replacing the welcome email send mechanism with automation run rails, which is the primary focus of the PR.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, explaining the migration to automations-based welcome emails, referencing the design document, and detailing implementation specifics.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch NY-1190

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

This is the bulk of the change, but aims to follow the agreed-upon spec
closely.
Nothing subscribes to this yet. Wait for an upcoming commit.
@codecov
Copy link
Copy Markdown

codecov bot commented Apr 12, 2026

Codecov Report

❌ Patch coverage is 67.54177% with 136 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.45%. Comparing base (eaff0a2) to head (835508a).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
ghost/core/core/shared/one-at-a-time.js 0.00% 62 Missing ⚠️
...server/services/welcome-email-automations/index.js 0.00% 43 Missing ⚠️
.../server/services/welcome-email-automations/poll.js 93.82% 16 Missing ⚠️
...bers/members-api/repositories/member-repository.js 67.74% 10 Missing ⚠️
ghost/core/core/boot.js 0.00% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #27364      +/-   ##
==========================================
- Coverage   73.45%   73.45%   -0.01%     
==========================================
  Files        1546     1550       +4     
  Lines      123752   124141     +389     
  Branches    14968    15011      +43     
==========================================
+ Hits        90908    91189     +281     
- Misses      31823    31930     +107     
- Partials     1021     1022       +1     
Flag Coverage Δ
admin-tests 54.38% <ø> (-0.01%) ⬇️
e2e-tests 73.45% <67.54%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@EvanHahn EvanHahn marked this pull request as ready for review April 12, 2026 23:00
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (2)
ghost/core/core/server/services/welcome-email-automations/events/start-automations-poll-event.js (1)

3-15: Consider removing timestamp until it is consumed.

In the current flow, poll scheduling uses DB ready_at and callback timing; this field appears unused, so dropping it would simplify the event contract.

✂️ Optional simplification
 module.exports = class StartAutomationsPollEvent {
-    /**
-     * `@param` {Date} timestamp
-     */
-    constructor(timestamp) {
+    constructor() {
         this.data = null;
-        this.timestamp = timestamp;
     }
@@
     static create() {
-        return new StartAutomationsPollEvent(new Date());
+        return new StartAutomationsPollEvent();
     }
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ghost/core/core/server/services/welcome-email-automations/events/start-automations-poll-event.js`
around lines 3 - 15, The StartAutomationsPollEvent constructor currently stores
an unused timestamp field; remove the timestamp parameter and the this.timestamp
assignment from the constructor (and update its JSDoc), and update the static
create() factory to call new StartAutomationsPollEvent() with no arguments; also
search for any external usages of the timestamp property or constructor
signature (StartAutomationsPollEvent, constructor, create) and update callers if
necessary so the event contract no longer exposes timestamp until it is actually
consumed.
ghost/core/core/shared/one-at-a-time.js (1)

24-28: Add observability for swallowed failures.

Catching and fully ignoring errors here can hide persistent poll failures. Keep the control-flow behavior, but report the error.

🔧 Suggested minimal refactor
-const oneAtATime = (fn) => {
+const oneAtATime = (fn, {onError = () => {}} = {}) => {
@@
-        } catch {
-            // noop
+        } catch (err) {
+            onError(err);
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/shared/one-at-a-time.js` around lines 24 - 28, The catch
block currently swallows errors from awaiting fn(); change the anonymous catch
to catch (err) and report the error before continuing control flow: capture the
thrown error (err) and call the project's logging/observability API (e.g., call
a logger.error or error-reporter.captureException) or at minimum
console.error(err) so failures are visible while preserving the no-op behavior
otherwise; update the try { await fn(); } catch (...) block to use catch (err) {
/* report err */ }.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@ghost/core/core/server/services/members/members-api/repositories/member-repository.js`:
- Line 408: The call to
dispatchEvent(this.dispatchEvent(StartAutomationsPollEvent.create(),
memberAddOptions)) passes an event whose data is null, which causes
dispatchEvent()'s rejection path to throw when it attempts to read
event.data.memberId; fix by ensuring the StartAutomationsPollEvent instance
includes a non-null data.memberId (e.g. call
StartAutomationsPollEvent.create({memberId: member.id}) or otherwise populate
data before passing to dispatchEvent), or alternatively wrap the call-site to
pass a safe data object into dispatchEvent; target the
StartAutomationsPollEvent.create() invocation and the dispatchEvent(...) call so
the event always has data.memberId when dispatched.

In `@ghost/core/core/server/services/welcome-email-automations/index.js`:
- Around line 24-37: The current enqueueAnotherPollAt implementation uses an
in-memory setTimeout inside domainEvents.subscribe(StartAutomationsPollEvent,
...) so future polls are lost on restart; change enqueueAnotherPollAt to persist
or re-arm scheduled wake-ups instead of using setTimeout by delegating to the
app Scheduler (or writing the ready_at to the DB and registering it with the
Scheduler on boot) so that poll({ memberWelcomeEmailService, ... }) will be
invoked at the saved ready_at even after a restart; update the subscription/boot
path to restore any persisted future polls and ensure enqueuePollNow remains
available for immediate wake-ups.

In `@ghost/core/core/server/services/welcome-email-automations/poll.js`:
- Around line 181-220: The code currently calls
memberWelcomeEmailService.api.send before persisting AutomatedEmailRecipient and
calling markExited, so if the DB work fails the catch block can schedule a retry
and duplicate the email; fix by making the send idempotent or moving it after
the DB transaction commits: either (A) insert AutomatedEmailRecipient and call
markExited within db.knex.transaction first, commit, then call
memberWelcomeEmailService.api.send using the saved member_uuid/member_email so
send only occurs after successful DB work, or (B) add an existence check of
AutomatedEmailRecipient (e.g., before send look up AutomatedEmailRecipient by
member_uuid and automated_email_id) and skip sending if the recipient row
already exists; update references to memberWelcomeEmailService.api.send,
AutomatedEmailRecipient.add, markExited, markRetry, markMaxAttemptsExceeded and
enqueueAnotherPollAt accordingly.
- Around line 52-80: The current logic selects eligible runs then updates them
later, allowing races; change the selection to claim rows atomically using
row-level locking within the same transaction by applying FOR UPDATE SKIP LOCKED
(Knex .forUpdate().skipLocked()) on the query against
welcome_email_automation_runs (the query that populates runs) so only unlocked
rows are returned, then perform the update (setting step_started_at/updated_at
and incrementing step_attempts) on those locked rows within the same trx; ensure
you still return the selected rows (populate runs) for processing and remove the
separate ids push/select-then-update race.
- Around line 175-180: Before calling api.send, add explicit bailout checks
after Member.findOne by: 1) verify the member exists for run.member_id and, if
not, mark the run as a terminal/stale state (so it won't be retried) and return;
2) check the member's subscription/unsubscribe flags and, if unsubscribed, mark
the run terminal and return; 3) check the member's current status (free/paid or
other relevant fields) against whatever state the run expects, and if it has
changed, mark the run terminal and return; implement these checks in poll.js
around the Member.findOne result and ensure you update the run record (set a
terminal status like "exited"/"stale" on the run) before returning so api.send
is never called for stale/deleted/unsubscribed/changed members.

---

Nitpick comments:
In
`@ghost/core/core/server/services/welcome-email-automations/events/start-automations-poll-event.js`:
- Around line 3-15: The StartAutomationsPollEvent constructor currently stores
an unused timestamp field; remove the timestamp parameter and the this.timestamp
assignment from the constructor (and update its JSDoc), and update the static
create() factory to call new StartAutomationsPollEvent() with no arguments; also
search for any external usages of the timestamp property or constructor
signature (StartAutomationsPollEvent, constructor, create) and update callers if
necessary so the event contract no longer exposes timestamp until it is actually
consumed.

In `@ghost/core/core/shared/one-at-a-time.js`:
- Around line 24-28: The catch block currently swallows errors from awaiting
fn(); change the anonymous catch to catch (err) and report the error before
continuing control flow: capture the thrown error (err) and call the project's
logging/observability API (e.g., call a logger.error or
error-reporter.captureException) or at minimum console.error(err) so failures
are visible while preserving the no-op behavior otherwise; update the try {
await fn(); } catch (...) block to use catch (err) { /* report err */ }.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 38827c5b-b619-45a1-a2cb-623d81c8e6c3

📥 Commits

Reviewing files that changed from the base of the PR and between b9fc97d and f5ac912.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (14)
  • ghost/core/core/boot.js
  • ghost/core/core/server/services/members/api.js
  • ghost/core/core/server/services/members/members-api/members-api.js
  • ghost/core/core/server/services/members/members-api/repositories/member-repository.js
  • ghost/core/core/server/services/welcome-email-automations/events/start-automations-poll-event.js
  • ghost/core/core/server/services/welcome-email-automations/index.js
  • ghost/core/core/server/services/welcome-email-automations/poll.js
  • ghost/core/core/shared/one-at-a-time.js
  • ghost/core/package.json
  • ghost/core/test/integration/services/member-welcome-emails.test.js
  • ghost/core/test/integration/services/welcome-email-automations/poll.test.js
  • ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js
  • ghost/core/test/unit/server/services/welcome-email-automations/index.test.js
  • ghost/core/test/unit/shared/one-at-a-time.test.js

}

this.dispatchEvent(StartOutboxProcessingEvent.create({memberId: member.id}), memberAddOptions);
this.dispatchEvent(StartAutomationsPollEvent.create(), memberAddOptions);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

StartAutomationsPollEvent can break dispatchEvent()'s rollback handler.

These new calls pass an event whose data is null, but dispatchEvent()'s rejection path logs event.data.memberId. If the surrounding transaction rolls back, that log statement throws while handling the rollback, so this "best effort" path can become an unhandled rejection instead.

Also applies to: 1507-1507

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ghost/core/core/server/services/members/members-api/repositories/member-repository.js`
at line 408, The call to
dispatchEvent(this.dispatchEvent(StartAutomationsPollEvent.create(),
memberAddOptions)) passes an event whose data is null, which causes
dispatchEvent()'s rejection path to throw when it attempts to read
event.data.memberId; fix by ensuring the StartAutomationsPollEvent instance
includes a non-null data.memberId (e.g. call
StartAutomationsPollEvent.create({memberId: member.id}) or otherwise populate
data before passing to dispatchEvent), or alternatively wrap the call-site to
pass a safe data object into dispatchEvent; target the
StartAutomationsPollEvent.create() invocation and the dispatchEvent(...) call so
the event always has data.memberId when dispatched.

Comment on lines +24 to +37
domainEvents.subscribe(StartAutomationsPollEvent, oneAtATime(async () => {
await poll({
memberWelcomeEmailService,
enqueueAnotherPollNow: enqueuePollNow,
enqueueAnotherPollAt: (date) => {
// TODO(NY-1191): Use Scheduler instead of `setTimeout`.
setTimeout(() => {
enqueuePollNow();
}, date.getTime() - Date.now());
}
});
}));

enqueuePollNow();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Persist or re-arm future polls across restarts.

enqueueAnotherPollAt() only creates an in-memory setTimeout. If Ghost restarts after a run is saved with a future ready_at, that wake-up is lost. On boot this service only fires an immediate poll, and poll() only claims ready_at <= now, so those future retries can sit forever until some unrelated event happens to dispatch another poll.

🧰 Tools
🪛 GitHub Check: SonarCloud Code Analysis

[warning] 29-29: Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=TryGhost_Ghost&issues=AZ2D0ORrdAmmyX96kDXm&open=AZ2D0ORrdAmmyX96kDXm&pullRequest=27364

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/services/welcome-email-automations/index.js` around
lines 24 - 37, The current enqueueAnotherPollAt implementation uses an in-memory
setTimeout inside domainEvents.subscribe(StartAutomationsPollEvent, ...) so
future polls are lost on restart; change enqueueAnotherPollAt to persist or
re-arm scheduled wake-ups instead of using setTimeout by delegating to the app
Scheduler (or writing the ready_at to the DB and registering it with the
Scheduler on boot) so that poll({ memberWelcomeEmailService, ... }) will be
invoked at the saved ready_at even after a restart; update the subscription/boot
path to restore any persisted future polls and ensure enqueuePollNow remains
available for immediate wake-ups.

Comment on lines +52 to +80
return db.knex.transaction(async (trx) => {
/** @type {Run[]} */
const runs = await trx('welcome_email_automation_runs as r')
.join('welcome_email_automations as a', 'r.welcome_email_automation_id', 'a.id')
.join('welcome_email_automated_emails as e', 'r.next_welcome_email_automated_email_id', 'e.id')
.whereNotNull('r.next_welcome_email_automated_email_id')
.where('r.ready_at', '<=', now)
.where(function () {
this.whereNull('r.step_started_at').orWhere('r.step_started_at', '<', lockCutoff);
})
.select('r.id', 'r.member_id', 'r.step_attempts', 'r.next_welcome_email_automated_email_id', 'a.slug as automation_slug', 'e.id as automated_email_id')
.limit(MAX_RUNS_PER_BATCH);

if (runs.length === 0) {
return runs;
}

/** @type {string[]} */ const ids = [];

for (const run of runs) {
ids.push(run.id);
run.step_attempts += 1;
}

await trx('welcome_email_automation_runs').whereIn('id', ids).update({
step_started_at: now,
step_attempts: db.knex.raw('step_attempts + 1'),
updated_at: now
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Claim runs atomically before processing them.

fetchAndLockRuns() reads eligible rows first and only updates them afterward. Two Ghost instances can select the same run before either update commits, so both workers can send the same welcome email. This needs a real claim step with row locking or another atomic ownership mechanism.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/services/welcome-email-automations/poll.js` around
lines 52 - 80, The current logic selects eligible runs then updates them later,
allowing races; change the selection to claim rows atomically using row-level
locking within the same transaction by applying FOR UPDATE SKIP LOCKED (Knex
.forUpdate().skipLocked()) on the query against welcome_email_automation_runs
(the query that populates runs) so only unlocked rows are returned, then perform
the update (setting step_started_at/updated_at and incrementing step_attempts)
on those locked rows within the same trx; ensure you still return the selected
rows (populate runs) for processing and remove the separate ids
push/select-then-update race.

Comment on lines +175 to +180
const member = await Member.findOne({id: run.member_id}, {withRelated: ['newsletters']});

// TODO(NY-1192): Bail if member no longer exists
// TODO(NY-1193): Bail if member is unsubscribed
// TODO(NY-1194): Bail if member's status has changed

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Exit stale runs before calling api.send().

The TODOs here are currently user-visible behavior gaps: a deleted member turns into 10 pointless retries, and a retry can still send after unsubscribe or after the member changes free/paid state. These should be explicit exit paths, not generic send failures. If helpful, I can sketch the bailout flow.

🧰 Tools
🪛 GitHub Check: SonarCloud Code Analysis

[warning] 178-178: Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=TryGhost_Ghost&issues=AZ2D0OZTdAmmyX96kDXp&open=AZ2D0OZTdAmmyX96kDXp&pullRequest=27364


[warning] 179-179: Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=TryGhost_Ghost&issues=AZ2D0OZTdAmmyX96kDXq&open=AZ2D0OZTdAmmyX96kDXq&pullRequest=27364


[warning] 177-177: Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=TryGhost_Ghost&issues=AZ2D0OZTdAmmyX96kDXo&open=AZ2D0OZTdAmmyX96kDXo&pullRequest=27364

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/services/welcome-email-automations/poll.js` around
lines 175 - 180, Before calling api.send, add explicit bailout checks after
Member.findOne by: 1) verify the member exists for run.member_id and, if not,
mark the run as a terminal/stale state (so it won't be retried) and return; 2)
check the member's subscription/unsubscribe flags and, if unsubscribed, mark the
run terminal and return; 3) check the member's current status (free/paid or
other relevant fields) against whatever state the run expects, and if it has
changed, mark the run terminal and return; implement these checks in poll.js
around the Member.findOne result and ensure you update the run record (set a
terminal status like "exited"/"stale" on the run) before returning so api.send
is never called for stale/deleted/unsubscribed/changed members.

Comment on lines +181 to +220
await memberWelcomeEmailService.api.send({
member: {
name: member.get('name'),
email: member.get('email'),
uuid: member.get('uuid')
},
memberStatus
});

await db.knex.transaction(async (transacting) => {
await AutomatedEmailRecipient.add({
member_id: run.member_id,
automated_email_id: run.automated_email_id,
member_uuid: member.get('uuid'),
member_email: member.get('email'),
member_name: member.get('name')
}, {transacting});

// TODO(NY-1195): Advance to next email when there are additional ones

await markExited(run.id, 'finished', transacting);
});
} catch (err) {
logging.error(
{
system: {
event: 'welcome_email_automations.send_failed',
run_id: run.id
},
err
},
`${LOG_KEY} Failed to send welcome email for run ${run.id}`
);

if (run.step_attempts < MAX_ATTEMPTS) {
const retryAt = new Date(Date.now() + RETRY_DELAY_MS);
await markRetry(run.id, retryAt);
enqueueAnotherPollAt(retryAt);
} else {
await markMaxAttemptsExceeded(run.id);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

A successful send can be retried and duplicated here.

The email is sent before the recipient row and markExited() transaction. If the provider accepts the message and that DB work fails afterward, the catch block schedules a retry and the member can receive the same welcome email again. This boundary needs idempotency, not a blind retry.

🧰 Tools
🪛 GitHub Check: SonarCloud Code Analysis

[warning] 199-199: Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=TryGhost_Ghost&issues=AZ2D0OZTdAmmyX96kDXr&open=AZ2D0OZTdAmmyX96kDXr&pullRequest=27364

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/services/welcome-email-automations/poll.js` around
lines 181 - 220, The code currently calls memberWelcomeEmailService.api.send
before persisting AutomatedEmailRecipient and calling markExited, so if the DB
work fails the catch block can schedule a retry and duplicate the email; fix by
making the send idempotent or moving it after the DB transaction commits: either
(A) insert AutomatedEmailRecipient and call markExited within
db.knex.transaction first, commit, then call memberWelcomeEmailService.api.send
using the saved member_uuid/member_email so send only occurs after successful DB
work, or (B) add an existence check of AutomatedEmailRecipient (e.g., before
send look up AutomatedEmailRecipient by member_uuid and automated_email_id) and
skip sending if the recipient row already exists; update references to
memberWelcomeEmailService.api.send, AutomatedEmailRecipient.add, markExited,
markRetry, markMaxAttemptsExceeded and enqueueAnotherPollAt accordingly.

const {oneAtATime} = require('../../../core/shared/one-at-a-time');

/**
* Ponyfill of `Promise.withResolvers`.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

labels
}, {...memberAddOptions, transacting});

const timestamp = eventData.created_at || newMember.get('created_at');
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Was it ok to remove this? Same question applies to timestamp below.


return db.knex.transaction(async (trx) => {
/** @type {Run[]} */
const runs = await trx('welcome_email_automation_runs as r')
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I wasn't sure if this should use Bookshelf, but I didn't.

}

memberWelcomeEmailService.init();
await memberWelcomeEmailService.api.loadMemberWelcomeEmails();
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'm pretty sure this needs to be done over time in case welcome emails change. Let me know if there's a way around this.

@EvanHahn
Copy link
Copy Markdown
Contributor Author

I feel that both the Codecov and SonarQube reports are not helpful in this case.

@EvanHahn EvanHahn requested a review from troyciesco April 12, 2026 23:14
@sonarqubecloud
Copy link
Copy Markdown

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant