Skip to content

✨ Added manual welcome email trigger for invite-only sites#28344

Open
deRonbrown wants to merge 1 commit into
TryGhost:mainfrom
deRonbrown:feature/manual-welcome-email
Open

✨ Added manual welcome email trigger for invite-only sites#28344
deRonbrown wants to merge 1 commit into
TryGhost:mainfrom
deRonbrown:feature/manual-welcome-email

Conversation

@deRonbrown
Copy link
Copy Markdown

@deRonbrown deRonbrown commented Jun 3, 2026

Why

In Ghost 6.18.0, welcome emails were wired into the self-signup flow only — memberRepository.create gates the auto-trigger on source='member'. Sites running in invite-only access mode (where staff manually add members) had no way to send the welcome email, even though the automation itself was set up and active.

What

Two opt-in paths that reuse the existing welcome email automation:

  • "Send welcome email" toggle on the new member form at /ghost/#/members/new — defaults off, exposed via a new send_welcome_email option on POST /members.
  • "Send / Resend welcome email" action in the member detail page dropdown — exposed via a new POST /members/:id/welcome_email. The label, button text, and confirmation modal copy adapt to a new welcome_email_sent_at field, surfaced from the existing automated_email_recipients table (the same source the activity feed reads). No new event model.

Both paths call memberRepository.triggerMemberSignupAutomation so the existing poller handles delivery and activity tracking. Both are short-circuited by the email_verification_required host limit. The resend endpoint returns 400 for ineligible statuses (comped) and 404 when no automation is configured.

Screenshots

Send welcome email toggle on the new member form:

send_welcome_from_new_member

Resend welcome email action on the member detail page:

resend_from_member_settings

Tests

  • Unit (`test/unit/.../members-bread-service.test.js`): 5 cases for `pickWelcomeEmailStatus` — free/paid/gift mappings, comped → null, unknown statuses → null
  • E2E (`test/e2e-api/admin/members.test.js`): 7 cases under a new `Welcome email` describe — happy path enqueues a run, no-automation → 404, ineligible status → 400, unknown member → 404, verification-required → blocked; plus `?send_welcome_email=true` on create both enqueueing and being suppressed by verification
  • 5 snapshot files updated additively (welcome_email_sent_at: null)

Verification

Check Result
Lint (core + admin) clean
Core unit 6321 passing
Welcome-email integration 26 passing
Member e2e (7 files incl. new) 188 passing
Admin acceptance 1008 passing

Checklist

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

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 3, 2026

Review Change Stack

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: ec5de7ef-ced1-4c87-b173-421320537256

📥 Commits

Reviewing files that changed from the base of the PR and between def5293 and 94feefd.

⛔ Files ignored due to path filters (5)
  • ghost/core/test/e2e-api/admin/__snapshots__/member-commenting.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/admin/__snapshots__/members-edit-subscriptions.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/admin/__snapshots__/members-newsletters.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/members/__snapshots__/webhooks.test.js.snap is excluded by !**/*.snap
📒 Files selected for processing (16)
  • ghost/admin/app/adapters/member.js
  • ghost/admin/app/components/gh-member-settings-form.hbs
  • ghost/admin/app/components/members/modals/send-welcome-email.hbs
  • ghost/admin/app/components/members/modals/send-welcome-email.js
  • ghost/admin/app/controllers/member.js
  • ghost/admin/app/models/member.js
  • ghost/admin/app/routes/member/new.js
  • ghost/admin/app/serializers/member.js
  • ghost/admin/app/templates/member.hbs
  • ghost/core/core/server/api/endpoints/members.js
  • ghost/core/core/server/api/endpoints/utils/serializers/output/members.js
  • ghost/core/core/server/services/members/members-api/members-api.js
  • ghost/core/core/server/services/members/members-api/services/member-bread-service.js
  • ghost/core/core/server/web/api/endpoints/admin/routes.js
  • ghost/core/test/e2e-api/admin/members.test.js
  • ghost/core/test/unit/server/services/members/members-api/services/members-bread-service.test.js
🚧 Files skipped from review as they are similar to previous changes (11)
  • ghost/core/core/server/web/api/endpoints/admin/routes.js
  • ghost/admin/app/adapters/member.js
  • ghost/core/test/unit/server/services/members/members-api/services/members-bread-service.test.js
  • ghost/admin/app/templates/member.hbs
  • ghost/admin/app/serializers/member.js
  • ghost/core/core/server/services/members/members-api/members-api.js
  • ghost/admin/app/routes/member/new.js
  • ghost/core/test/e2e-api/admin/members.test.js
  • ghost/admin/app/models/member.js
  • ghost/admin/app/controllers/member.js
  • ghost/core/core/server/services/members/members-api/services/member-bread-service.js

Walkthrough

Adds end-to-end welcome email support for members: admin UI checkbox and modal, client model/adapter/serializer changes, new POST endpoints and admin route, MemberBREADService logic to determine eligibility/automation presence and trigger welcome-email automations, output serialization of welcome_email_sent_at, and unit and e2e tests covering creation and explicit send flows.

Possibly related PRs

  • TryGhost/Ghost#28263: Introduces the automation trigger API and repository wiring used by MemberBREADService to enqueue member signup welcome-email runs.

Suggested labels

ok to merge for me

Suggested reviewers

  • troyciesco
  • cmraible
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding manual welcome email trigger functionality for invite-only sites, which is the core purpose of all changes across both admin and core.
Description check ✅ Passed The description comprehensively explains the why, what, how, and testing approach, directly relating to the changeset with concrete details about both UI paths, backend implementation, and verification results.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

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: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
ghost/core/core/server/api/endpoints/utils/serializers/output/members.js (1)

8-25: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Register a serializer for sendWelcomeEmail.

ghost/core/core/server/api/endpoints/members.js now exposes controller.sendWelcomeEmail, but this module still doesn't export a matching serializer. Without that, the new endpoint won't return the standard {members: [...]} payload shape the admin member flows already use.

Suggested fix
 module.exports = {
     browse: createSerializer('browse', paginatedMembers),
     read: createSerializer('read', singleMember),
     edit: createSerializer('edit', singleMember),
     add: createSerializer('add', singleMember),
+    sendWelcomeEmail: createSerializer('sendWelcomeEmail', singleMember),
     destroy: createSerializer('destroy', passthrough),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ghost/core/core/server/api/endpoints/utils/serializers/output/members.js`
around lines 8 - 25, The module is missing a serializer entry for the new
controller action sendWelcomeEmail; add a property named sendWelcomeEmail that
uses createSerializer with the action name 'sendWelcomeEmail' and the
singleMember serializer (i.e., add sendWelcomeEmail:
createSerializer('sendWelcomeEmail', singleMember)) so the endpoint returns the
standard {members: [...]} payload shape.
🧹 Nitpick comments (2)
ghost/core/test/unit/server/services/members/members-api/services/members-bread-service.test.js (1)

39-39: 💤 Low value

Consider testing null explicitly.

While undefined and empty string are tested, null is a distinct value in JavaScript and could behave differently. Adding an explicit assertion for null would make the test coverage more complete.

📝 Suggested addition
 it('returns null for unknown statuses', function () {
     assert.equal(service.pickWelcomeEmailStatus('unknown'), null);
     assert.equal(service.pickWelcomeEmailStatus(''), null);
     assert.equal(service.pickWelcomeEmailStatus(undefined), null);
+    assert.equal(service.pickWelcomeEmailStatus(null), null);
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@ghost/core/test/unit/server/services/members/members-api/services/members-bread-service.test.js`
at line 39, Add an explicit assertion for null to the existing test for
pickWelcomeEmailStatus: call service.pickWelcomeEmailStatus(null) and assert it
returns null (e.g., assert.equal(service.pickWelcomeEmailStatus(null), null));
this complements the existing checks for undefined and empty string and ensures
null is handled as expected in the pickWelcomeEmailStatus logic.
ghost/admin/app/components/members/modals/send-welcome-email.js (1)

28-32: ⚡ Quick win

Closing the modal on error hides the GhTaskButton failure/retry state.

On failure you call this.args.close(false) and then re-throw. Re-throwing normally lets GhTaskButton surface an error/retry state, but the button is destroyed once the modal closes, so that rethrow has no visible effect and the user must reopen the modal to retry. Consider not closing on error (let the button show the failure and allow an in-place retry); the showAPIError notification already informs the user.

♻️ Proposed change
         } catch (e) {
             this.notifications.showAPIError(e, {key: 'member.send-welcome-email'});
-            this.args.close(false);
             throw e;
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ghost/admin/app/components/members/modals/send-welcome-email.js` around lines
28 - 32, The catch block in the send-welcome-email flow closes the modal
(this.args.close(false)) before re-throwing, which destroys the GhTaskButton and
prevents its error/retry state from being shown; remove the
this.args.close(false) call from the catch so the modal stays open, call
this.notifications.showAPIError(e, {key: 'member.send-welcome-email'}) as-is,
and re-throw the error so GhTaskButton can display failure and allow in-place
retry.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@ghost/core/core/server/services/members/members-api/services/member-bread-service.js`:
- Around line 89-107: Scope the query to only welcome-email deliveries: update
the fetchWelcomeEmailSentAt method (and the other helper that also reads latest
AutomatedEmailRecipient) to add a filter that limits results to the
welcome-email automation before ordering by created_at. In the
AutomatedEmailRecipient query (the call chain on
this.AutomatedEmailRecipient.forge().query(...).fetch(...)), join or filter by
the related automated email/automation identifier (e.g., automated_emails.slug
or automation_type) that corresponds to the welcome-email (commonly "welcome" or
"welcome-email") and only then orderBy('created_at').limit(1); apply the same
change to the second helper that queries AutomatedEmailRecipient so only
welcome-email deliveries are considered.
- Around line 583-601: The current code uses model.get('status') (the model
returned from memberRepository.create) when calling pickWelcomeEmailStatus, but
subsequent calls like linkStripeCustomer and setComplimentarySubscription can
mutate the persisted member (to 'paid' or 'comped'), so re-read the persisted
status before deciding which automation to trigger: after the post-create
mutation helpers (linkStripeCustomer / setComplimentarySubscription) either
fetch the updated member from the repository (e.g. this.memberRepository.read or
similar) and call pickWelcomeEmailStatus on that fresh record, or change those
helper functions to return the updated model and use that returned
model.get('status') when invoking pickWelcomeEmailStatus and
triggerMemberSignupAutomation.

In `@ghost/core/test/e2e-api/admin/members.test.js`:
- Around line 3647-3651: The test for the POST /members/:id/welcome_email path
uses agent.post(`/members/${memberId}/welcome_email`) and
.matchBodySnapshot(...) but doesn't assert the HTTP status; add an explicit
status assertion (use .expectStatus(429)) to the agent chain for this request to
ensure the host-limit error returns the expected 429 status before calling
.matchBodySnapshot.

---

Outside diff comments:
In `@ghost/core/core/server/api/endpoints/utils/serializers/output/members.js`:
- Around line 8-25: The module is missing a serializer entry for the new
controller action sendWelcomeEmail; add a property named sendWelcomeEmail that
uses createSerializer with the action name 'sendWelcomeEmail' and the
singleMember serializer (i.e., add sendWelcomeEmail:
createSerializer('sendWelcomeEmail', singleMember)) so the endpoint returns the
standard {members: [...]} payload shape.

---

Nitpick comments:
In `@ghost/admin/app/components/members/modals/send-welcome-email.js`:
- Around line 28-32: The catch block in the send-welcome-email flow closes the
modal (this.args.close(false)) before re-throwing, which destroys the
GhTaskButton and prevents its error/retry state from being shown; remove the
this.args.close(false) call from the catch so the modal stays open, call
this.notifications.showAPIError(e, {key: 'member.send-welcome-email'}) as-is,
and re-throw the error so GhTaskButton can display failure and allow in-place
retry.

In
`@ghost/core/test/unit/server/services/members/members-api/services/members-bread-service.test.js`:
- Line 39: Add an explicit assertion for null to the existing test for
pickWelcomeEmailStatus: call service.pickWelcomeEmailStatus(null) and assert it
returns null (e.g., assert.equal(service.pickWelcomeEmailStatus(null), null));
this complements the existing checks for undefined and empty string and ensures
null is handled as expected in the pickWelcomeEmailStatus logic.
🪄 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: 4fc385a6-4030-4552-9c60-423a67bae8a0

📥 Commits

Reviewing files that changed from the base of the PR and between 87b99d0 and def5293.

⛔ Files ignored due to path filters (5)
  • ghost/core/test/e2e-api/admin/__snapshots__/member-commenting.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/admin/__snapshots__/members-edit-subscriptions.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/admin/__snapshots__/members-newsletters.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/members/__snapshots__/webhooks.test.js.snap is excluded by !**/*.snap
📒 Files selected for processing (16)
  • ghost/admin/app/adapters/member.js
  • ghost/admin/app/components/gh-member-settings-form.hbs
  • ghost/admin/app/components/members/modals/send-welcome-email.hbs
  • ghost/admin/app/components/members/modals/send-welcome-email.js
  • ghost/admin/app/controllers/member.js
  • ghost/admin/app/models/member.js
  • ghost/admin/app/routes/member/new.js
  • ghost/admin/app/serializers/member.js
  • ghost/admin/app/templates/member.hbs
  • ghost/core/core/server/api/endpoints/members.js
  • ghost/core/core/server/api/endpoints/utils/serializers/output/members.js
  • ghost/core/core/server/services/members/members-api/members-api.js
  • ghost/core/core/server/services/members/members-api/services/member-bread-service.js
  • ghost/core/core/server/web/api/endpoints/admin/routes.js
  • ghost/core/test/e2e-api/admin/members.test.js
  • ghost/core/test/unit/server/services/members/members-api/services/members-bread-service.test.js

Comment on lines +89 to +107
async fetchWelcomeEmailSentAt(memberId) {
if (!this.AutomatedEmailRecipient || !memberId) {
return null;
}

try {
const latest = await this.AutomatedEmailRecipient.forge()
.query((qb) => {
qb.where('member_id', memberId)
.orderBy('created_at', 'desc')
.limit(1);
})
.fetch({require: false});
return latest ? latest.get('created_at') : null;
} catch (err) {
logging.error(`Failed to load welcome email sent timestamp for member ${memberId}.`);
logging.error(err);
return null;
}
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 | 🏗️ Heavy lift

Scope welcome_email_sent_at to welcome-email deliveries only.

Both helpers currently take the newest automated_email_recipients row for the member without filtering by automation/email type. Any later automated email can overwrite this field, so the UI will report a welcome email as already sent when only some other automated email was delivered. Filter these queries to the welcome-email automations before taking the latest timestamp.

Also applies to: 117-141

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@ghost/core/core/server/services/members/members-api/services/member-bread-service.js`
around lines 89 - 107, Scope the query to only welcome-email deliveries: update
the fetchWelcomeEmailSentAt method (and the other helper that also reads latest
AutomatedEmailRecipient) to add a filter that limits results to the
welcome-email automation before ordering by created_at. In the
AutomatedEmailRecipient query (the call chain on
this.AutomatedEmailRecipient.forge().query(...).fetch(...)), join or filter by
the related automated email/automation identifier (e.g., automated_emails.slug
or automation_type) that corresponds to the welcome-email (commonly "welcome" or
"welcome-email") and only then orderBy('created_at').limit(1); apply the same
change to the second helper that queries AutomatedEmailRecipient so only
welcome-email deliveries are considered.

Comment thread ghost/core/test/e2e-api/admin/members.test.js
In 6.18.0 welcome emails were wired into the self-signup flow only —
admin-created members are intentionally skipped (the member repository
gates the auto-enqueue on source='member'). Invite-only sites had no
way to send the welcome email to manually-added members.

This adds two opt-in paths that reuse the existing welcome email
automation:

- A "Send welcome email" toggle on /ghost/#/members/new, exposed via a
  new send_welcome_email option on POST /members.
- A "Send/Resend welcome email" button on the member detail page,
  exposed via POST /members/:id/welcome_email. The button label and
  confirmation modal copy adapt to a new welcome_email_sent_at field
  surfaced from automated_email_recipients — the same source the
  existing activity feed reads, so no new event model is needed.

Both paths enqueue a WelcomeEmailAutomationRun so the existing poll
handles delivery and activity tracking. Both are short-circuited by
the email_verification_required host limit. The resend endpoint
returns 400 for ineligible statuses (comped) and 404 when no
automation is configured.
@deRonbrown deRonbrown force-pushed the feature/manual-welcome-email branch from def5293 to 94feefd Compare June 3, 2026 19:42
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