diff --git a/ghost/admin/app/helpers/parse-member-event.js b/ghost/admin/app/helpers/parse-member-event.js
index 6852ff1a197..eab5b5fd940 100644
--- a/ghost/admin/app/helpers/parse-member-event.js
+++ b/ghost/admin/app/helpers/parse-member-event.js
@@ -141,7 +141,11 @@ export default class ParseMemberEventHelper extends Helper {
}
if (event.type === 'gift_purchase_event') {
- icon = 'subscriptions';
+ icon = 'gift';
+ }
+
+ if (event.type === 'gift_redemption_event') {
+ icon = 'gift';
}
if (event.type === 'email_change_event') {
@@ -268,6 +272,11 @@ export default class ParseMemberEventHelper extends Helper {
return `Purchased a gift subscription for ${formattedAmount} (${tierName}, ${duration} ${cadenceLabel})`;
}
+
+ if (event.type === 'gift_redemption_event') {
+ const tierName = event.data.tier_name;
+ return `Started paid subscription (${tierName}) via gift`;
+ }
}
/**
diff --git a/ghost/admin/app/utils/member-event-types.js b/ghost/admin/app/utils/member-event-types.js
index 0a8512bf3ea..9471d257f1f 100644
--- a/ghost/admin/app/utils/member-event-types.js
+++ b/ghost/admin/app/utils/member-event-types.js
@@ -34,10 +34,12 @@ export function toggleEventType(eventType, eventTypes) {
excludedEvents.delete('payment_event');
excludedEvents.delete('donation_event');
excludedEvents.delete('gift_purchase_event');
+ excludedEvents.delete('gift_redemption_event');
} else {
excludedEvents.add('payment_event');
excludedEvents.add('donation_event');
excludedEvents.add('gift_purchase_event');
+ excludedEvents.add('gift_redemption_event');
}
} else {
if (excludedEvents.has(eventType)) {
diff --git a/ghost/admin/public/assets/icons/event-gift.svg b/ghost/admin/public/assets/icons/event-gift.svg
new file mode 100644
index 00000000000..bcbaae2d703
--- /dev/null
+++ b/ghost/admin/public/assets/icons/event-gift.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/ghost/admin/tests/unit/utils/member-event-types-test.js b/ghost/admin/tests/unit/utils/member-event-types-test.js
index 162926a37f4..afd74adcf41 100644
--- a/ghost/admin/tests/unit/utils/member-event-types-test.js
+++ b/ghost/admin/tests/unit/utils/member-event-types-test.js
@@ -24,7 +24,7 @@ describe('Unit | Utility | event-type-utils', function () {
const newExcludedEvents = toggleEventType('payment_event', eventTypes);
- expect(newExcludedEvents).to.equal('payment_event,donation_event,gift_purchase_event');
+ expect(newExcludedEvents).to.equal('payment_event,donation_event,gift_purchase_event,gift_redemption_event');
});
it('should toggle both payment_event and donation_event off when toggling payment_event off', function () {
diff --git a/ghost/core/core/server/services/gifts/constants.ts b/ghost/core/core/server/services/gifts/constants.ts
index 8738d2a97ff..1edb50db532 100644
--- a/ghost/core/core/server/services/gifts/constants.ts
+++ b/ghost/core/core/server/services/gifts/constants.ts
@@ -1 +1,3 @@
export const GIFT_EXPIRY_DAYS = 365;
+export const GIFT_REMINDER_LEAD_DAYS = 7;
+export const GIFT_REMINDER_FLOOR_DAYS = 3;
diff --git a/ghost/core/core/server/services/gifts/email-templates/gift-reminder.hbs b/ghost/core/core/server/services/gifts/email-templates/gift-reminder.hbs
new file mode 100644
index 00000000000..2f8b842e397
--- /dev/null
+++ b/ghost/core/core/server/services/gifts/email-templates/gift-reminder.hbs
@@ -0,0 +1,110 @@
+
+
+
+
+
+ Your gift subscription is ending soon
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{#if siteIconUrl}}
+
+
+
+ {{/if}}
+
+
+ Your gift subscription is ending soon
+ {{#if memberName}}Hi {{memberName}}, your{{else}}Your{{/if}} gift subscription to {{siteTitle}} ends on {{gift.consumesAt}}. Continue with a paid subscription to keep your access.
+
+
+
+
+
+
+
+ Gift subscription
+ {{gift.tierName}} • {{gift.cadenceLabel}}
+ Ends on
+ {{gift.consumesAt}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ This message was sent from {{siteDomain}} to {{memberEmail}}
+
+
+
+
+ You received this email because your gift subscription to {{siteTitle}} is ending soon.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ghost/core/core/server/services/gifts/email-templates/gift-reminder.ts b/ghost/core/core/server/services/gifts/email-templates/gift-reminder.ts
new file mode 100644
index 00000000000..d0c990e3f47
--- /dev/null
+++ b/ghost/core/core/server/services/gifts/email-templates/gift-reminder.ts
@@ -0,0 +1,33 @@
+export interface GiftReminderData {
+ siteTitle: string;
+ siteUrl: string;
+ siteIconUrl: string | null;
+ siteDomain: string;
+ accentColor: string | undefined;
+ memberEmail: string;
+ memberName: string | null;
+ gift: {
+ tierName: string;
+ cadenceLabel: string;
+ consumesAt: string;
+ manageSubscriptionUrl: string;
+ };
+}
+
+export function renderText(data: GiftReminderData): string {
+ const greeting = data.memberName ? `Hi ${data.memberName},` : 'Hi,';
+
+ return `${greeting}
+
+Your gift subscription to ${data.siteTitle} ends on ${data.gift.consumesAt}.
+
+Gift subscription: ${data.gift.tierName} • ${data.gift.cadenceLabel}
+
+To keep your access, continue with a paid subscription before your gift ends:
+${data.gift.manageSubscriptionUrl}
+
+---
+
+Sent to ${data.memberEmail} from ${data.siteDomain}.
+You received this email because your gift subscription to ${data.siteTitle} is ending soon.`;
+}
diff --git a/ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts b/ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts
index 3b1fa3688ca..64fabec2bfb 100644
--- a/ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts
+++ b/ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts
@@ -1,6 +1,6 @@
import errors from '@tryghost/errors';
import {Gift, type GiftCadence, type GiftStatus} from './gift';
-import type {GiftRepository, RepositoryTransactionOptions} from './gift-repository';
+import type {FindPendingReminderOptions, GiftRepository, RepositoryTransactionOptions} from './gift-repository';
type BookshelfDocument = {
save(data: Partial, options?: unknown): Promise;
@@ -40,6 +40,7 @@ type GiftRow = {
consumed_at: Date | null;
expired_at: Date | null;
refunded_at: Date | null;
+ consumes_soon_reminder_sent_at: Date | null;
};
type GiftBookshelfModel = BookshelfModel;
@@ -95,6 +96,18 @@ export class GiftBookshelfRepository implements GiftRepository {
return collection.models.map(model => this.toGift(model));
}
+ async findPendingReminder({now, reminderLeadMs, reminderFloorMs, transacting}: FindPendingReminderOptions): Promise {
+ const upper = new Date(now.getTime() + reminderLeadMs).toISOString();
+ const lower = new Date(now.getTime() + reminderFloorMs).toISOString();
+
+ const collection = await this.model.findAll({
+ filter: `status:redeemed+consumes_at:<='${upper}'+consumes_at:>'${lower}'+consumes_soon_reminder_sent_at:null`,
+ transacting
+ });
+
+ return collection.models.map(model => this.toGift(model));
+ }
+
async create(gift: Gift, options: RepositoryTransactionOptions = {}) {
await this.model.add(this.toRow(gift), options);
}
@@ -140,7 +153,8 @@ export class GiftBookshelfRepository implements GiftRepository {
redeemed_at: gift.redeemedAt,
consumed_at: gift.consumedAt,
expired_at: gift.expiredAt,
- refunded_at: gift.refundedAt
+ refunded_at: gift.refundedAt,
+ consumes_soon_reminder_sent_at: gift.consumesSoonReminderSentAt
};
}
@@ -166,7 +180,8 @@ export class GiftBookshelfRepository implements GiftRepository {
redeemedAt: json.redeemed_at,
consumedAt: json.consumed_at,
expiredAt: json.expired_at,
- refundedAt: json.refunded_at
+ refundedAt: json.refunded_at,
+ consumesSoonReminderSentAt: json.consumes_soon_reminder_sent_at ?? null
});
}
}
diff --git a/ghost/core/core/server/services/gifts/gift-email-renderer.ts b/ghost/core/core/server/services/gifts/gift-email-renderer.ts
index 08d71ea7e87..89f8bc81bdd 100644
--- a/ghost/core/core/server/services/gifts/gift-email-renderer.ts
+++ b/ghost/core/core/server/services/gifts/gift-email-renderer.ts
@@ -3,11 +3,14 @@ import path from 'node:path';
import Handlebars from 'handlebars';
import type {GiftPurchaseConfirmationData} from './email-templates/gift-purchase-confirmation';
import {renderText as renderPurchaseConfirmationText} from './email-templates/gift-purchase-confirmation';
+import type {GiftReminderData} from './email-templates/gift-reminder';
+import {renderText as renderReminderText} from './email-templates/gift-reminder';
export class GiftEmailRenderer {
private readonly handlebars: typeof Handlebars;
private purchaseConfirmationTemplate: HandlebarsTemplateDelegate | null = null;
+ private reminderTemplate: HandlebarsTemplateDelegate | null = null;
constructor() {
this.handlebars = Handlebars.create();
@@ -25,4 +28,17 @@ export class GiftEmailRenderer {
text: renderPurchaseConfirmationText(data)
};
}
+
+ async renderReminder(data: GiftReminderData): Promise<{html: string; text: string}> {
+ if (!this.reminderTemplate) {
+ const source = await fs.readFile(path.join(__dirname, './email-templates/gift-reminder.hbs'), 'utf8');
+
+ this.reminderTemplate = this.handlebars.compile(source);
+ }
+
+ return {
+ html: this.reminderTemplate(data),
+ text: renderReminderText(data)
+ };
+ }
}
diff --git a/ghost/core/core/server/services/gifts/gift-email-service.ts b/ghost/core/core/server/services/gifts/gift-email-service.ts
index 64e0da34212..d102762931a 100644
--- a/ghost/core/core/server/services/gifts/gift-email-service.ts
+++ b/ghost/core/core/server/services/gifts/gift-email-service.ts
@@ -35,6 +35,15 @@ interface PurchaseConfirmationData {
expiresAt: Date;
}
+interface ReminderData {
+ memberEmail: string;
+ memberName: string | null;
+ tierName: string;
+ cadence: 'month' | 'year';
+ duration: number;
+ consumesAt: Date;
+}
+
export class GiftEmailService {
private readonly mailer: Mailer;
private readonly settingsCache: SettingsCache;
@@ -69,8 +78,7 @@ export class GiftEmailService {
const giftLink = `${siteUrl.replace(/\/$/, '')}/gift/${token}`;
- const unit = cadence === 'month' ? 'month' : 'year';
- const cadenceLabel = duration === 1 ? `1 ${unit}` : `${duration} ${unit}s`;
+ const cadenceLabel = duration === 1 ? `1 ${cadence}` : `${duration} ${cadence}s`;
// Pre-build a mailto: URL the buyer can click to open their default mail
// client with a friendly draft already filled in. Recipient is left blank
@@ -108,6 +116,43 @@ export class GiftEmailService {
});
}
+ async sendReminder({memberEmail, memberName, tierName, cadence, duration, consumesAt}: ReminderData): Promise {
+ const siteDomain = this.siteDomain;
+ const siteUrl = this.urlUtils.getSiteUrl();
+ const siteTitle = this.settingsCache.get('title') ?? siteDomain;
+
+ const cadenceLabel = duration === 1 ? `1 ${cadence}` : `${duration} ${cadence}s`;
+
+ const manageSubscriptionUrl = new URL('#/portal/account', siteUrl).href;
+
+ const templateData = {
+ siteTitle,
+ siteUrl,
+ siteIconUrl: this.blogIcon.getIconUrl({absolute: true, fallbackToDefault: false}),
+ siteDomain,
+ accentColor: this.settingsCache.get('accent_color'),
+ memberEmail,
+ memberName,
+ gift: {
+ tierName,
+ cadenceLabel,
+ consumesAt: moment(consumesAt).format('D MMM YYYY'),
+ manageSubscriptionUrl
+ }
+ };
+
+ const {html, text} = await this.renderer.renderReminder(templateData);
+
+ await this.mailer.send({
+ to: memberEmail,
+ subject: `Your gift subscription to ${siteTitle} is ending soon`,
+ html,
+ text,
+ from: this.getFromAddress(),
+ forceTextContent: true
+ });
+ }
+
private formatAmount({amount = 0, currency}: {amount?: number; currency?: string}): string {
if (!currency) {
return Intl.NumberFormat('en', {maximumFractionDigits: 2}).format(amount);
diff --git a/ghost/core/core/server/services/gifts/gift-repository.ts b/ghost/core/core/server/services/gifts/gift-repository.ts
index 5c9ed528d2d..03ef8d4bc93 100644
--- a/ghost/core/core/server/services/gifts/gift-repository.ts
+++ b/ghost/core/core/server/services/gifts/gift-repository.ts
@@ -5,12 +5,20 @@ export interface RepositoryTransactionOptions {
forUpdate?: boolean;
}
+export interface FindPendingReminderOptions {
+ now: Date;
+ reminderLeadMs: number;
+ reminderFloorMs: number;
+ transacting?: unknown;
+}
+
export interface GiftRepository {
existsByCheckoutSessionId(checkoutSessionId: string): Promise;
getByToken(token: string, options?: RepositoryTransactionOptions): Promise;
getByPaymentIntentId(paymentIntentId: string): Promise;
findPendingConsumption(): Promise;
findPendingExpiration(): Promise;
+ findPendingReminder(options: FindPendingReminderOptions): Promise;
create(gift: Gift, options?: RepositoryTransactionOptions): Promise;
update(gift: Gift, options?: RepositoryTransactionOptions): Promise;
transaction(callback: (transacting: unknown) => Promise): Promise;
diff --git a/ghost/core/core/server/services/gifts/gift-service.ts b/ghost/core/core/server/services/gifts/gift-service.ts
index 251dc34aca2..1e9d95f37e2 100644
--- a/ghost/core/core/server/services/gifts/gift-service.ts
+++ b/ghost/core/core/server/services/gifts/gift-service.ts
@@ -3,6 +3,9 @@ import logging from '@tryghost/logging';
import {Gift} from './gift';
import type {GiftRepository} from './gift-repository';
import tpl from '@tryghost/tpl';
+import {GIFT_REMINDER_FLOOR_DAYS, GIFT_REMINDER_LEAD_DAYS} from './constants';
+
+const MS_PER_DAY = 24 * 60 * 60 * 1000;
const errorMessages = {
giftSubscriptionsNotEnabled: 'Gift subscriptions are not enabled on this site.',
@@ -14,8 +17,17 @@ const errorMessages = {
paidMember: 'You already have an active subscription.'
};
+interface MemberModel {
+ id: string;
+ get(key: 'email'): string;
+ get(key: 'status'): string;
+ get(key: 'name'): string | null;
+ get(key: 'email_disabled'): boolean;
+ get(key: string): unknown;
+}
+
interface MemberRepository {
- get(filter: Record, options?: Record): Promise<{id: string; get(key: string): string | null} | null>;
+ get(filter: Record, options?: Record): Promise;
update(data: Record, options?: Record): Promise;
}
@@ -46,6 +58,14 @@ interface GiftEmailService {
duration: number;
expiresAt: Date;
}): Promise;
+ sendReminder(data: {
+ memberEmail: string;
+ memberName: string | null;
+ tierName: string;
+ cadence: 'month' | 'year';
+ duration: number;
+ consumesAt: Date;
+ }): Promise;
}
interface StaffServiceEmails {
@@ -89,6 +109,14 @@ interface GiftServiceDeps {
staffServiceEmails: StaffServiceEmails;
}
+interface ReminderSend {
+ memberEmail: string;
+ memberName: string | null;
+ cadence: 'month' | 'year';
+ duration: number;
+ consumesAt: Date;
+}
+
export class GiftService {
private readonly deps: GiftServiceDeps;
@@ -165,16 +193,8 @@ export class GiftService {
return true;
}
- async getByToken(token: string): Promise {
- const gift = await this.deps.giftRepository.getByToken(token);
-
- if (!gift) {
- throw new errors.NotFoundError({
- message: tpl(errorMessages.giftNotFound)
- });
- }
-
- return gift;
+ async getByToken(token: string): Promise {
+ return this.deps.giftRepository.getByToken(token);
}
async assertRedeemable(gift: Gift, memberStatus: string | null): Promise {
@@ -270,8 +290,8 @@ export class GiftService {
await this.deps.staffServiceEmails.notifyGiftSubscriptionStarted({
memberId: member.id,
- memberEmail: member.get('email')!,
- memberName: member.get('name') ?? null,
+ memberEmail: member.get('email'),
+ memberName: member.get('name'),
tierName: tier.name,
buyerEmail: redeemed.buyerEmail
});
@@ -390,4 +410,121 @@ export class GiftService {
return {expiredCount};
}
+
+ async processReminders(): Promise<{remindedCount: number; skippedCount: number; failedCount: number}> {
+ const now = new Date();
+ const toRemind = await this.deps.giftRepository.findPendingReminder({
+ now,
+ reminderLeadMs: GIFT_REMINDER_LEAD_DAYS * MS_PER_DAY,
+ reminderFloorMs: GIFT_REMINDER_FLOOR_DAYS * MS_PER_DAY
+ });
+
+ if (toRemind.length === 0) {
+ return {remindedCount: 0, skippedCount: 0, failedCount: 0};
+ }
+
+ let remindedCount = 0;
+ let skippedCount = 0;
+ let failedCount = 0;
+
+ for (const gift of toRemind) {
+ try {
+ const sent = await this.sendReminderForGift(gift.token);
+
+ if (sent) {
+ remindedCount += 1;
+ } else {
+ skippedCount += 1;
+ }
+ } catch (err) {
+ logging.error(err);
+
+ failedCount += 1;
+ }
+ }
+
+ return {remindedCount, skippedCount, failedCount};
+ }
+
+ private async sendReminderForGift(token: string): Promise {
+ const gift = await this.deps.giftRepository.getByToken(token);
+
+ if (!gift) {
+ return false;
+ }
+
+ const tier = await this.deps.tiersService.api.read(gift.tierId);
+
+ if (!tier) {
+ throw new errors.NotFoundError({message: `Tier not found for gift: ${gift.tierId}`});
+ }
+
+ const result = await this.deps.giftRepository.transaction(async (transacting): Promise => {
+ const locked = await this.deps.giftRepository.getByToken(token, {transacting, forUpdate: true});
+
+ if (!locked) {
+ return null;
+ }
+
+ if (
+ // Gift must still be active — a concurrent refund or early consume can happen
+ // between `findPendingReminder` and this re-read.
+ locked.status !== 'redeemed'
+ // Idempotency guard: another path (rerun, scheduler) may already have sent.
+ || locked.consumesSoonReminderSentAt !== null
+ // Narrows `redeemerMemberId` from `string | null` to `string` — always set for redeemed gifts.
+ || locked.redeemerMemberId === null
+ // Narrows `consumesAt` from `Date | null` to `Date` — always set for redeemed gifts.
+ || locked.consumesAt === null
+ ) {
+ return null;
+ }
+
+ const member = await this.deps.memberRepository.get(
+ {id: locked.redeemerMemberId},
+ {transacting, forUpdate: true}
+ );
+
+ // Record the reminder as sent before any skip or send below so we don't
+ // re-try gifts with permanently unreachable redeemers on every poll.
+ const reminded = locked.remind();
+
+ if (!reminded) {
+ return null;
+ }
+
+ await this.deps.giftRepository.update(reminded, {transacting});
+
+ if (!member) {
+ return null;
+ }
+
+ if (member.get('email_disabled')) {
+ return null;
+ }
+
+ return {
+ memberEmail: member.get('email'),
+ memberName: member.get('name'),
+ cadence: locked.cadence,
+ duration: locked.duration,
+ consumesAt: locked.consumesAt
+ };
+ });
+
+ if (!result) {
+ return false;
+ }
+
+ await this.deps.giftEmailService.sendReminder({
+ memberEmail: result.memberEmail,
+ memberName: result.memberName,
+ tierName: tier.name,
+ cadence: result.cadence,
+ duration: result.duration,
+ consumesAt: result.consumesAt
+ });
+
+ return true;
+ }
}
diff --git a/ghost/core/core/server/services/gifts/gift.ts b/ghost/core/core/server/services/gifts/gift.ts
index a70e1ee85b7..d60c4ec139c 100644
--- a/ghost/core/core/server/services/gifts/gift.ts
+++ b/ghost/core/core/server/services/gifts/gift.ts
@@ -28,6 +28,7 @@ interface GiftData {
consumedAt: Date | null;
expiredAt: Date | null;
refundedAt: Date | null;
+ consumesSoonReminderSentAt: Date | null;
}
export interface GiftFromPurchaseData {
@@ -63,6 +64,7 @@ export class Gift {
consumedAt: Date | null;
expiredAt: Date | null;
refundedAt: Date | null;
+ consumesSoonReminderSentAt: Date | null;
constructor(data: GiftData) {
this.token = data.token;
@@ -84,6 +86,7 @@ export class Gift {
this.consumedAt = data.consumedAt;
this.expiredAt = data.expiredAt;
this.refundedAt = data.refundedAt;
+ this.consumesSoonReminderSentAt = data.consumesSoonReminderSentAt;
}
static fromPurchase(data: GiftFromPurchaseData) {
@@ -102,7 +105,8 @@ export class Gift {
redeemedAt: null,
consumedAt: null,
expiredAt: null,
- refundedAt: null
+ refundedAt: null,
+ consumesSoonReminderSentAt: null
});
}
@@ -199,4 +203,15 @@ export class Gift {
expiredAt: new Date()
});
}
+
+ remind(): Gift | null {
+ if (this.consumesSoonReminderSentAt !== null) {
+ return null;
+ }
+
+ return new Gift({
+ ...this,
+ consumesSoonReminderSentAt: new Date()
+ });
+ }
}
diff --git a/ghost/core/core/server/services/members/jobs/index.js b/ghost/core/core/server/services/members/jobs/index.js
index 551369bdc96..804f3070633 100644
--- a/ghost/core/core/server/services/members/jobs/index.js
+++ b/ghost/core/core/server/services/members/jobs/index.js
@@ -3,7 +3,8 @@ const jobsService = require('../../jobs');
let hasScheduled = {
expiredComped: false,
- gifts: false,
+ giftCleanup: false,
+ giftReminders: false,
tokens: false
};
@@ -33,7 +34,11 @@ module.exports = {
},
async scheduleGiftCleanupJob() {
- return scheduleJob('gifts', 'clean-gifts', 'clean-gifts.js');
+ return scheduleJob('giftCleanup', 'clean-gifts', 'clean-gifts.js');
+ },
+
+ async scheduleGiftReminderJob() {
+ return scheduleJob('giftReminders', 'send-gift-reminders', 'send-gift-reminders.js');
},
async scheduleTokenCleanupJob() {
diff --git a/ghost/core/core/server/services/members/jobs/send-gift-reminders.js b/ghost/core/core/server/services/members/jobs/send-gift-reminders.js
new file mode 100644
index 00000000000..88dcc289506
--- /dev/null
+++ b/ghost/core/core/server/services/members/jobs/send-gift-reminders.js
@@ -0,0 +1,58 @@
+const {parentPort} = require('node:worker_threads');
+const debug = require('@tryghost/debug')('jobs:send-gift-reminders');
+
+// recurring job to send gift reminder emails.
+
+// Exit early when cancelled to prevent stalling shutdown. No cleanup needed
+// when cancelling as everything is idempotent — any gift that hasn't yet had
+// its reminder recorded will be picked up on the next run.
+function cancel() {
+ if (parentPort) {
+ parentPort.postMessage('Gift reminder job cancelled before completion');
+ parentPort.postMessage('cancelled');
+ } else {
+ setTimeout(() => {
+ process.exit(0);
+ }, 1000);
+ }
+}
+
+if (parentPort) {
+ parentPort.once('message', (message) => {
+ if (message === 'cancel') {
+ return cancel();
+ }
+ });
+}
+
+(async () => {
+ try {
+ const startDate = new Date();
+ debug('Starting gift reminder send');
+
+ const giftService = require('../../gifts');
+ await giftService.init();
+
+ const {remindedCount, skippedCount, failedCount} = await giftService.service.processReminders();
+
+ const endDate = new Date();
+ const message = `Sent ${remindedCount} gift reminders, skipped ${skippedCount}, failed ${failedCount} in ${endDate.valueOf() - startDate.valueOf()}ms`;
+
+ debug(message);
+
+ if (parentPort) {
+ parentPort.postMessage(message);
+ parentPort.postMessage('done');
+ } else {
+ setTimeout(() => {
+ process.exit(0);
+ }, 1000);
+ }
+ } catch (error) {
+ if (parentPort) {
+ parentPort.postMessage('done');
+ }
+
+ throw error;
+ }
+})();
diff --git a/ghost/core/core/server/services/members/members-api/repositories/event-repository.js b/ghost/core/core/server/services/members/members-api/repositories/event-repository.js
index e0f19eaa431..c3b59098c6b 100644
--- a/ghost/core/core/server/services/members/members-api/repositories/event-repository.js
+++ b/ghost/core/core/server/services/members/members-api/repositories/event-repository.js
@@ -85,7 +85,8 @@ module.exports = class EventRepository {
{type: 'login_event', action: 'getLoginEvents'},
{type: 'payment_event', action: 'getPaymentEvents'},
{type: 'email_change_event', action: 'getEmailChangeEvent'},
- {type: 'gift_purchase_event', action: 'getGiftPurchaseEvents'}
+ {type: 'gift_purchase_event', action: 'getGiftPurchaseEvents'},
+ {type: 'gift_redemption_event', action: 'getGiftRedemptionEvents'}
);
if (this._AutomatedEmailRecipient) {
@@ -474,6 +475,55 @@ module.exports = class EventRepository {
};
}
+ async getGiftRedemptionEvents(options = {}, filter) {
+ options = {
+ ...options,
+ withRelated: ['redeemer', 'tier'],
+ filter: 'redeemer_member_id:-null+custom:true',
+ useBasicCount: true,
+ mongoTransformer: chainTransformers(
+ // First set the filter manually
+ replaceCustomFilterTransformer(filter),
+
+ // Map the used keys in that filter
+ ...mapKeys({
+ 'data.created_at': 'redeemed_at',
+ 'data.member_id': 'redeemer_member_id'
+ })
+ )
+ };
+
+ if (options.order) {
+ options.order = options.order.replace(/created_at/g, 'redeemed_at');
+ }
+
+ const {data: models, meta} = await this._Gift.findPage(options);
+
+ const data = models.map((model) => {
+ const json = model.toJSON(options);
+
+ return {
+ type: 'gift_redemption_event',
+ data: {
+ id: json.id,
+ member: json.redeemer || null,
+ member_id: json.redeemer_member_id,
+ tier_name: json.tier?.name,
+ cadence: json.cadence,
+ duration: json.duration,
+ amount: json.amount,
+ currency: json.currency,
+ created_at: json.redeemed_at
+ }
+ };
+ });
+
+ return {
+ data,
+ meta
+ };
+ }
+
async getCommentEvents(options = {}, filter) {
options = {
...options,
diff --git a/ghost/core/core/server/services/members/service.js b/ghost/core/core/server/services/members/service.js
index f6616accac5..473ddac641d 100644
--- a/ghost/core/core/server/services/members/service.js
+++ b/ghost/core/core/server/services/members/service.js
@@ -166,9 +166,11 @@ module.exports = {
// Schedule daily cron job to clean expired tokens
memberJobs.scheduleTokenCleanupJob();
- // Schedule daily cron job to clean consumed and expired gifts
+ // Schedule daily cron jobs to clean up consumed/expired gifts and to
+ // send gift reminder emails.
if (labsService.isSet('giftSubscriptions')) {
memberJobs.scheduleGiftCleanupJob();
+ memberJobs.scheduleGiftReminderJob();
}
},
contentGating: require('./content-gating'),
diff --git a/ghost/core/core/server/web/gift-preview/app.js b/ghost/core/core/server/web/gift-preview/app.js
new file mode 100644
index 00000000000..7f1469b6003
--- /dev/null
+++ b/ghost/core/core/server/web/gift-preview/app.js
@@ -0,0 +1,16 @@
+const express = require('../../../shared/express');
+const errorHandler = require('@tryghost/mw-error-handler');
+const sentry = require('../../../shared/sentry');
+const controller = require('./controller');
+
+module.exports = function giftPreviewApp() {
+ const app = express('gift-preview');
+
+ app.get('/:token/image', controller.giftPreviewImage);
+ app.get('/:token', controller.giftPreview);
+
+ app.use(errorHandler.pageNotFound);
+ app.use(errorHandler.handleHTMLResponse(sentry));
+
+ return app;
+};
diff --git a/ghost/core/core/server/web/gift-preview/controller.js b/ghost/core/core/server/web/gift-preview/controller.js
new file mode 100644
index 00000000000..8b884eda9b6
--- /dev/null
+++ b/ghost/core/core/server/web/gift-preview/controller.js
@@ -0,0 +1,141 @@
+const errors = require('@tryghost/errors');
+const logging = require('@tryghost/logging');
+const {generateGiftPreviewImage} = require('./image');
+
+function getCadenceLabel(cadence, duration) {
+ return duration === 1 ? `1 ${cadence}` : `${duration} ${cadence}s`;
+}
+
+function escapeHtml(str) {
+ return str
+ .replaceAll('&', '&')
+ .replaceAll('"', '"')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>');
+}
+
+async function giftPreview(req, res) {
+ const labs = require('../../../shared/labs');
+ const giftService = require('../../services/gifts').service;
+ const tiersService = require('../../services/tiers');
+ const urlUtils = require('../../../shared/url-utils');
+ const settingsCache = require('../../../shared/settings-cache');
+
+ const siteUrl = urlUtils.getSiteUrl().replace(/\/$/, '');
+
+ if (!labs.isSet('giftSubscriptions')) {
+ return res.redirect(302, siteUrl + '/');
+ }
+
+ const {token} = req.params;
+ const siteTitle = settingsCache.get('title') || 'Ghost';
+
+ let gift;
+ let tier;
+
+ try {
+ gift = await giftService.getByToken(token);
+ tier = await tiersService.api.read(gift.tierId);
+
+ if (!tier) {
+ throw new errors.NotFoundError({message: `Tier not found: ${gift.tierId}`});
+ }
+ } catch (err) {
+ logging.warn(`Gift preview: failed to load required gift data, redirecting to homepage`, err);
+
+ return res.redirect(302, siteUrl + '/');
+ }
+
+ const cadenceLabel = getCadenceLabel(gift.cadence, gift.duration);
+ const ogTitle = `A gift membership to ${siteTitle}`;
+ const ogDescription = `${tier.name} \u00B7 ${cadenceLabel}`;
+ const ogImage = `${siteUrl}/gift/${encodeURIComponent(token)}/image`;
+ const ogUrl = `${siteUrl}/gift/${encodeURIComponent(token)}`;
+ const redirectUrl = `${siteUrl}/#/portal/gift/redeem/${encodeURIComponent(token)}`;
+
+ const html = `
+
+
+
+
+ ${escapeHtml(ogTitle)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Redeem your gift membership
+
+`;
+
+ res.set('Cache-Control', 'public, max-age=3600');
+ res.set('Content-Type', 'text/html; charset=utf-8');
+ res.send(html);
+}
+
+async function giftPreviewImage(req, res) {
+ const labs = require('../../../shared/labs');
+ const giftService = require('../../services/gifts').service;
+ const tiersService = require('../../services/tiers');
+ const settingsCache = require('../../../shared/settings-cache');
+
+ if (!labs.isSet('giftSubscriptions')) {
+ return res.sendStatus(404);
+ }
+
+ const token = req.params.token;
+
+ let gift;
+ let tier;
+
+ try {
+ gift = await giftService.getByToken(token);
+ tier = await tiersService.api.read(gift.tierId);
+
+ if (!tier) {
+ throw new errors.NotFoundError({message: `Tier not found: ${gift.tierId}`});
+ }
+ } catch (err) {
+ logging.warn('Gift preview image: failed to load required gift data', err);
+
+ return res.sendStatus(404);
+ }
+
+ const tierName = tier.name;
+ const cadenceLabel = getCadenceLabel(gift.cadence, gift.duration);
+ const accentColor = settingsCache.get('accent_color') || '#15171A';
+
+ try {
+ const png = await generateGiftPreviewImage({tierName, cadenceLabel, accentColor});
+
+ res.set('Content-Type', 'image/png');
+ res.set('Cache-Control', 'public, max-age=86400');
+ res.send(png);
+ } catch (err) {
+ logging.error('Gift OG image generation failed', err);
+
+ res.sendStatus(404);
+ }
+}
+
+module.exports = {
+ giftPreview,
+ giftPreviewImage
+};
diff --git a/ghost/core/core/server/web/gift-preview/image.js b/ghost/core/core/server/web/gift-preview/image.js
new file mode 100644
index 00000000000..b16ce30dad2
--- /dev/null
+++ b/ghost/core/core/server/web/gift-preview/image.js
@@ -0,0 +1,110 @@
+const CACHE_MAX_SIZE = 100;
+
+const cache = new Map();
+
+function cacheResult(key, value) {
+ if (cache.size >= CACHE_MAX_SIZE) {
+ const firstKey = cache.keys().next().value;
+
+ cache.delete(firstKey);
+ }
+
+ cache.set(key, value);
+}
+
+function escapeXml(str) {
+ return str
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll('\'', ''');
+}
+
+function hexToRgb(hex) {
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
+
+ if (!result) {
+ return {r: 0, g: 0, b: 0};
+ }
+
+ return {
+ r: Number.parseInt(result[1], 16),
+ g: Number.parseInt(result[2], 16),
+ b: Number.parseInt(result[3], 16)
+ };
+}
+
+function getBackgroundColor(accentColor) {
+ // Blend accent color at 6% opacity over white, matching Portal's redemption modal
+ const {r, g, b} = hexToRgb(accentColor);
+ const opacity = 0.06;
+ const blend = c => Math.round(255 + (c - 255) * opacity);
+
+ return `rgb(${blend(r)}, ${blend(g)}, ${blend(b)})`;
+}
+
+function buildSvg({tierName, cadenceLabel, accentColor}) {
+ const displayTier = tierName.length > 30 ? tierName.slice(0, 28) + '...' : tierName;
+ const bgColor = getBackgroundColor(accentColor);
+
+ // Gift box icon from Portal (apps/portal/src/images/icons/gift.svg)
+ // Original viewBox 0 0 24 24, scaled 4x and centered at x=600
+ const giftIcon = `
+
+
+
+
+
+
+
+ `;
+
+ return `
+
+
+
+
+ ${giftIcon}
+
+
+ GIFT MEMBERSHIP
+
+
+ You’ve been gifted a membership
+
+
+ ${escapeXml(displayTier)} · ${escapeXml(cadenceLabel)}
+ `;
+}
+
+async function generateGiftPreviewImage({tierName, cadenceLabel, accentColor}) {
+ const cacheKey = `${tierName}:${cadenceLabel}:${accentColor}`;
+
+ if (cache.has(cacheKey)) {
+ return cache.get(cacheKey);
+ }
+
+ const imageTransform = require('@tryghost/image-transform');
+ const svg = buildSvg({tierName, cadenceLabel, accentColor});
+ const image = await imageTransform.resizeFromBuffer(Buffer.from(svg), {
+ width: 1200,
+ format: 'png',
+ withoutEnlargement: false,
+ timeout: 10
+ });
+
+ cacheResult(cacheKey, image);
+
+ return image;
+}
+
+module.exports = {
+ generateGiftPreviewImage
+};
diff --git a/ghost/core/core/server/web/gift-preview/index.js b/ghost/core/core/server/web/gift-preview/index.js
new file mode 100644
index 00000000000..a9612f49fbf
--- /dev/null
+++ b/ghost/core/core/server/web/gift-preview/index.js
@@ -0,0 +1 @@
+module.exports = require('./app');
diff --git a/ghost/core/core/server/web/parent/frontend.js b/ghost/core/core/server/web/parent/frontend.js
index 5d345e6c84e..548c199a957 100644
--- a/ghost/core/core/server/web/parent/frontend.js
+++ b/ghost/core/core/server/web/parent/frontend.js
@@ -19,6 +19,7 @@ module.exports = (routerConfig) => {
frontendApp.lazyUse('/members', require('../members'));
frontendApp.lazyUse('/webmentions', require('../webmentions'));
+ frontendApp.lazyUse('/gift', require('../gift-preview'));
frontendApp.use('/', require('../../../frontend/web')(routerConfig));
return frontendApp;
diff --git a/ghost/core/test/integration/services/members/send-gift-reminders.test.js b/ghost/core/test/integration/services/members/send-gift-reminders.test.js
new file mode 100644
index 00000000000..601127dbb54
--- /dev/null
+++ b/ghost/core/test/integration/services/members/send-gift-reminders.test.js
@@ -0,0 +1,201 @@
+const assert = require('node:assert/strict');
+const sinon = require('sinon');
+const {agentProvider, fixtureManager, mockManager} = require('../../../utils/e2e-framework');
+const models = require('../../../../core/server/models');
+
+const MS_PER_DAY = 24 * 60 * 60 * 1000;
+
+// This test exercises the gift reminder polling path end-to-end against a real
+// database and the real GiftService/Repository wiring. It calls
+// `giftService.service.processReminders()` directly rather than spawning the
+// worker thread — the worker script is a thin wrapper and spawning it in-process
+// means we can't intercept the email transport on the parent thread.
+
+describe('Gift reminder processing', function () {
+ let giftService;
+ let emailMockReceiver;
+ let paidTier;
+ let redeemerMember;
+ let giftSequence = 0;
+
+ before(async function () {
+ const agent = await agentProvider.getAdminAPIAgent();
+
+ await fixtureManager.init('newsletters', 'members:newsletters');
+ await agent.loginAsOwner();
+
+ giftService = require('../../../../core/server/services/gifts');
+ await giftService.init();
+
+ paidTier = await models.Product.findOne({type: 'paid'}, {require: true});
+ });
+
+ beforeEach(async function () {
+ mockManager.mockLabsEnabled('giftSubscriptions');
+ emailMockReceiver = mockManager.mockMail();
+
+ redeemerMember = await models.Member.add({
+ email: `gift-redeemer-${Date.now()}-${Math.random()}@example.com`,
+ name: 'Gift Redeemer',
+ status: 'gift',
+ email_disabled: false
+ });
+ });
+
+ afterEach(async function () {
+ await models.Gift.query().del();
+
+ if (redeemerMember) {
+ await models.Member.destroy({id: redeemerMember.id});
+ }
+
+ mockManager.restore();
+ sinon.restore();
+ });
+
+ async function createRedeemedGift(options = {}) {
+ const {
+ consumesAt,
+ consumesSoonReminderSentAt = null,
+ redeemerId = redeemerMember.id,
+ overrides = {}
+ } = options;
+
+ giftSequence += 1;
+ const sequence = giftSequence;
+ const now = new Date();
+ const expiresAt = new Date(now.getTime() + 365 * MS_PER_DAY);
+
+ return await models.Gift.add({
+ token: `reminder-test-token-${sequence}-${Date.now()}`,
+ buyer_email: `gift-buyer-${sequence}@example.com`,
+ buyer_member_id: null,
+ redeemer_member_id: redeemerId,
+ tier_id: paidTier.id,
+ cadence: 'year',
+ duration: 1,
+ currency: 'usd',
+ amount: 5000,
+ stripe_checkout_session_id: `cs_reminder_${sequence}_${Date.now()}`,
+ stripe_payment_intent_id: `pi_reminder_${sequence}_${Date.now()}`,
+ consumes_at: consumesAt,
+ expires_at: expiresAt,
+ status: 'redeemed',
+ purchased_at: now,
+ redeemed_at: now,
+ consumed_at: null,
+ expired_at: null,
+ refunded_at: null,
+ consumes_soon_reminder_sent_at: consumesSoonReminderSentAt,
+ ...overrides
+ });
+ }
+
+ it('sends a reminder email for gifts with consumes_at in (now+3d, now+7d] and records the reminder', async function () {
+ const now = new Date();
+ const inWindow = new Date(now.getTime() + 5 * MS_PER_DAY);
+ const gift = await createRedeemedGift({consumesAt: inWindow});
+
+ const result = await giftService.service.processReminders();
+
+ assert.equal(result.remindedCount, 1);
+ assert.equal(result.skippedCount, 0);
+ assert.equal(result.failedCount, 0);
+
+ emailMockReceiver.assertSentEmailCount(1);
+
+ const sent = emailMockReceiver.getSentEmail(0);
+
+ assert.equal(sent.to, redeemerMember.get('email'));
+ assert.match(sent.subject, /ending soon/);
+
+ const reloaded = await models.Gift.findOne({token: gift.get('token')}, {require: true});
+
+ assert.ok(reloaded.get('consumes_soon_reminder_sent_at'), 'Gift should be marked as reminded');
+ });
+
+ it('does not send a reminder for gifts that consume too soon (inside the floor)', async function () {
+ const now = new Date();
+ const tooSoon = new Date(now.getTime() + 2 * MS_PER_DAY);
+ const gift = await createRedeemedGift({consumesAt: tooSoon});
+
+ const result = await giftService.service.processReminders();
+
+ assert.equal(result.remindedCount, 0);
+ assert.equal(result.skippedCount, 0);
+ assert.equal(result.failedCount, 0);
+
+ emailMockReceiver.assertSentEmailCount(0);
+
+ const reloaded = await models.Gift.findOne({token: gift.get('token')}, {require: true});
+
+ assert.equal(reloaded.get('consumes_soon_reminder_sent_at'), null);
+ });
+
+ it('does not send a reminder for gifts that consume too far in the future', async function () {
+ const now = new Date();
+ const tooFar = new Date(now.getTime() + 30 * MS_PER_DAY);
+ const gift = await createRedeemedGift({consumesAt: tooFar});
+
+ const result = await giftService.service.processReminders();
+
+ assert.equal(result.remindedCount, 0);
+ assert.equal(result.skippedCount, 0);
+ assert.equal(result.failedCount, 0);
+
+ emailMockReceiver.assertSentEmailCount(0);
+
+ const reloaded = await models.Gift.findOne({token: gift.get('token')}, {require: true});
+
+ assert.equal(reloaded.get('consumes_soon_reminder_sent_at'), null);
+ });
+
+ it('does not re-send a reminder for gifts that have already been reminded', async function () {
+ const now = new Date();
+ const inWindow = new Date(now.getTime() + 5 * MS_PER_DAY);
+ const alreadyStamped = new Date(now.getTime() - MS_PER_DAY);
+
+ await createRedeemedGift({consumesAt: inWindow, consumesSoonReminderSentAt: alreadyStamped});
+
+ const result = await giftService.service.processReminders();
+
+ // findPendingReminder's NQL filter excludes gifts that have already been
+ // reminded, so the gift never enters the per-gift loop — counts stay at
+ // zero.
+ assert.equal(result.remindedCount, 0);
+ assert.equal(result.skippedCount, 0);
+ assert.equal(result.failedCount, 0);
+ emailMockReceiver.assertSentEmailCount(0);
+ });
+
+ it('marks the gift as reminded but does not email when the redeemer has email_disabled', async function () {
+ await models.Member.edit({email_disabled: true}, {id: redeemerMember.id});
+
+ const now = new Date();
+ const inWindow = new Date(now.getTime() + 5 * MS_PER_DAY);
+ const gift = await createRedeemedGift({consumesAt: inWindow});
+
+ const result = await giftService.service.processReminders();
+
+ assert.equal(result.remindedCount, 0);
+ assert.equal(result.skippedCount, 1);
+ assert.equal(result.failedCount, 0);
+ emailMockReceiver.assertSentEmailCount(0);
+
+ const reloaded = await models.Gift.findOne({token: gift.get('token')}, {require: true});
+
+ assert.ok(reloaded.get('consumes_soon_reminder_sent_at'), 'Gift should still be marked as reminded to prevent retries');
+ });
+
+ it('only sends one email across consecutive runs', async function () {
+ const now = new Date();
+ const inWindow = new Date(now.getTime() + 5 * MS_PER_DAY);
+
+ await createRedeemedGift({consumesAt: inWindow});
+
+ await giftService.service.processReminders();
+ await giftService.service.processReminders();
+
+ emailMockReceiver.assertSentEmailCount(1);
+ });
+});
diff --git a/ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts b/ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts
index c0a6fd3350f..3beefcf7765 100644
--- a/ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts
+++ b/ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts
@@ -137,7 +137,8 @@ describe('GiftBookshelfRepository', function () {
redeemedAt: new Date('2030-01-01T00:00:00.000Z'),
consumedAt: null,
expiredAt: null,
- refundedAt: null
+ refundedAt: null,
+ consumesSoonReminderSentAt: null
});
await repository.update(gift, {transacting: 'trx'});
@@ -180,7 +181,8 @@ describe('GiftBookshelfRepository', function () {
redeemedAt: null,
consumedAt: null,
expiredAt: null,
- refundedAt: null
+ refundedAt: null,
+ consumesSoonReminderSentAt: null
});
await assert.rejects(
@@ -270,6 +272,184 @@ describe('GiftBookshelfRepository', function () {
assert.ok(filterDate <= after);
});
+ it('finds gifts pending reminders within the configured window that have not yet received a reminder', async function () {
+ const GiftModel = {
+ add: sinon.stub(),
+ transaction: sinon.stub(),
+ findOne: sinon.stub(),
+ findAll: sinon.stub().resolves({
+ models: [{
+ toJSON() {
+ return {
+ token: 'gift-token',
+ buyer_email: 'buyer@example.com',
+ buyer_member_id: 'buyer_member_1',
+ redeemer_member_id: 'member_2',
+ tier_id: 'tier_1',
+ cadence: 'year',
+ duration: 1,
+ currency: 'usd',
+ amount: 5000,
+ stripe_checkout_session_id: 'cs_123',
+ stripe_payment_intent_id: 'pi_456',
+ consumes_at: new Date('2026-04-20T00:00:00.000Z'),
+ expires_at: new Date('2030-01-01T00:00:00.000Z'),
+ status: 'redeemed',
+ purchased_at: new Date('2025-01-01T00:00:00.000Z'),
+ redeemed_at: new Date('2025-04-20T00:00:00.000Z'),
+ consumed_at: null,
+ expired_at: null,
+ refunded_at: null,
+ consumes_soon_reminder_sent_at: null
+ };
+ }
+ }]
+ })
+ };
+ const repository = new GiftBookshelfRepository({GiftModel});
+
+ const now = new Date('2026-04-16T00:00:00.000Z');
+ const reminderLeadMs = 7 * 24 * 60 * 60 * 1000;
+ const reminderFloorMs = 3 * 24 * 60 * 60 * 1000;
+
+ const gifts = await repository.findPendingReminder({
+ now,
+ reminderLeadMs,
+ reminderFloorMs,
+ transacting: 'trx'
+ });
+
+ assert.equal(gifts.length, 1);
+ assert.equal(gifts[0].token, 'gift-token');
+ assert.equal(gifts[0].consumesSoonReminderSentAt, null);
+
+ sinon.assert.calledOnce(GiftModel.findAll);
+
+ const callArgs = GiftModel.findAll.getCall(0).args[0];
+ const filterArg: string = callArgs.filter;
+ const upperIso = new Date(now.getTime() + reminderLeadMs).toISOString();
+ const lowerIso = new Date(now.getTime() + reminderFloorMs).toISOString();
+
+ assert.equal(callArgs.transacting, 'trx');
+ assert.ok(filterArg.startsWith('status:redeemed'));
+ assert.ok(filterArg.includes(`consumes_at:<='${upperIso}'`));
+ assert.ok(filterArg.includes(`consumes_at:>'${lowerIso}'`));
+ assert.ok(filterArg.includes('consumes_soon_reminder_sent_at:null'));
+ });
+
+ it('reads consumes_soon_reminder_sent_at into the domain gift', async function () {
+ const reminderSentAt = new Date('2026-04-13T00:00:00.000Z');
+
+ const GiftModel = {
+ add: sinon.stub(),
+ transaction: sinon.stub(),
+ findOne: sinon.stub().resolves({
+ save: sinon.stub(),
+ set: sinon.stub(),
+ toJSON() {
+ return {
+ token: 'gift-token',
+ buyer_email: 'buyer@example.com',
+ buyer_member_id: 'buyer_member_1',
+ redeemer_member_id: 'member_2',
+ tier_id: 'tier_1',
+ cadence: 'year',
+ duration: 1,
+ currency: 'usd',
+ amount: 5000,
+ stripe_checkout_session_id: 'cs_123',
+ stripe_payment_intent_id: 'pi_456',
+ consumes_at: new Date('2026-04-20T00:00:00.000Z'),
+ expires_at: new Date('2030-01-01T00:00:00.000Z'),
+ status: 'redeemed',
+ purchased_at: new Date('2025-01-01T00:00:00.000Z'),
+ redeemed_at: new Date('2025-04-20T00:00:00.000Z'),
+ consumed_at: null,
+ expired_at: null,
+ refunded_at: null,
+ consumes_soon_reminder_sent_at: reminderSentAt
+ };
+ }
+ }),
+ findAll: sinon.stub()
+ };
+ const repository = new GiftBookshelfRepository({GiftModel});
+
+ const gift = await repository.getByToken('gift-token');
+
+ assert.ok(gift);
+ assert.equal(gift.consumesSoonReminderSentAt?.toISOString(), reminderSentAt.toISOString());
+ });
+
+ it('writes consumes_soon_reminder_sent_at through update', async function () {
+ const existing = {
+ save: sinon.stub().resolves(undefined),
+ set: sinon.stub(),
+ toJSON() {
+ return {
+ token: 'gift-token',
+ buyer_email: 'buyer@example.com',
+ buyer_member_id: 'buyer_member_1',
+ redeemer_member_id: 'member_2',
+ tier_id: 'tier_1',
+ cadence: 'year',
+ duration: 1,
+ currency: 'usd',
+ amount: 5000,
+ stripe_checkout_session_id: 'cs_123',
+ stripe_payment_intent_id: 'pi_456',
+ consumes_at: new Date('2026-04-20T00:00:00.000Z'),
+ expires_at: new Date('2030-01-01T00:00:00.000Z'),
+ status: 'redeemed',
+ purchased_at: new Date('2025-01-01T00:00:00.000Z'),
+ redeemed_at: new Date('2025-04-20T00:00:00.000Z'),
+ consumed_at: null,
+ expired_at: null,
+ refunded_at: null,
+ consumes_soon_reminder_sent_at: null
+ };
+ }
+ };
+ const GiftModel = {
+ add: sinon.stub(),
+ transaction: sinon.stub(),
+ findOne: sinon.stub().resolves(existing),
+ findAll: sinon.stub()
+ };
+ const repository = new GiftBookshelfRepository({GiftModel});
+ const reminderSentAt = new Date('2026-04-13T00:00:00.000Z');
+ const gift = new Gift({
+ token: 'gift-token',
+ buyerEmail: 'buyer@example.com',
+ buyerMemberId: 'buyer_member_1',
+ redeemerMemberId: 'member_2',
+ tierId: 'tier_1',
+ cadence: 'year',
+ duration: 1,
+ currency: 'usd',
+ amount: 5000,
+ stripeCheckoutSessionId: 'cs_123',
+ stripePaymentIntentId: 'pi_456',
+ consumesAt: new Date('2026-04-20T00:00:00.000Z'),
+ expiresAt: new Date('2030-01-01T00:00:00.000Z'),
+ status: 'redeemed',
+ purchasedAt: new Date('2025-01-01T00:00:00.000Z'),
+ redeemedAt: new Date('2025-04-20T00:00:00.000Z'),
+ consumedAt: null,
+ expiredAt: null,
+ refundedAt: null,
+ consumesSoonReminderSentAt: reminderSentAt
+ });
+
+ await repository.update(gift, {transacting: 'trx'});
+
+ sinon.assert.calledOnce(existing.save);
+
+ const savedRow = existing.save.firstCall.args[0];
+
+ assert.equal(savedRow.consumes_soon_reminder_sent_at, reminderSentAt);
+ });
+
it('finds gifts pending expiration using current time', async function () {
const before = new Date();
diff --git a/ghost/core/test/unit/server/services/gifts/gift-controller.test.ts b/ghost/core/test/unit/server/services/gifts/gift-controller.test.ts
index a69e07b6d59..72bc434f468 100644
--- a/ghost/core/test/unit/server/services/gifts/gift-controller.test.ts
+++ b/ghost/core/test/unit/server/services/gifts/gift-controller.test.ts
@@ -39,6 +39,7 @@ describe('GiftController', function () {
consumedAt: null,
expiredAt: null,
refundedAt: null,
+ consumesSoonReminderSentAt: null,
...overrides
});
}
diff --git a/ghost/core/test/unit/server/services/gifts/gift-email-service.test.js b/ghost/core/test/unit/server/services/gifts/gift-email-service.test.js
index 09cb8f3c19d..c16e57e40ed 100644
--- a/ghost/core/test/unit/server/services/gifts/gift-email-service.test.js
+++ b/ghost/core/test/unit/server/services/gifts/gift-email-service.test.js
@@ -130,4 +130,62 @@ describe('GiftEmailService', function () {
sinon.assert.calledWith(mailer.send, sinon.match.has('text', sinon.match('gift subscription on example.com')));
});
+
+ describe('sendReminder', function () {
+ const reminderData = {
+ memberEmail: 'member@example.com',
+ memberName: 'Member Name',
+ tierName: 'Gold',
+ cadence: 'year',
+ duration: 1,
+ consumesAt: new Date('2026-04-23T00:00:00.000Z')
+ };
+
+ it('sends to the redeemer with a site-scoped subject and from address', async function () {
+ await service.sendReminder(reminderData);
+
+ sinon.assert.calledOnce(mailer.send);
+ sinon.assert.calledWith(mailer.send, sinon.match({
+ to: 'member@example.com',
+ subject: 'Your gift subscription to Test Site is ending soon',
+ from: 'Test Site '
+ }));
+ });
+
+ it('includes tier name, cadence, consumesAt, and manage subscription url in both HTML and text', async function () {
+ await service.sendReminder(reminderData);
+
+ const msg = mailer.send.getCall(0).args[0];
+
+ for (const field of ['html', 'text']) {
+ sinon.assert.match(msg[field], sinon.match('Gold'));
+ sinon.assert.match(msg[field], sinon.match('1 year'));
+ sinon.assert.match(msg[field], sinon.match('23 Apr 2026'));
+ sinon.assert.match(msg[field], sinon.match('https://example.com/#/portal/account'));
+ }
+ });
+
+ it('uses a generic greeting when the member has no name', async function () {
+ await service.sendReminder({...reminderData, memberName: null});
+
+ const msg = mailer.send.getCall(0).args[0];
+
+ sinon.assert.match(msg.text, sinon.match(/^Hi,/));
+ });
+
+ it('includes the member name when provided', async function () {
+ await service.sendReminder(reminderData);
+
+ const msg = mailer.send.getCall(0).args[0];
+
+ sinon.assert.match(msg.text, sinon.match('Hi Member Name,'));
+ sinon.assert.match(msg.html, sinon.match('Hi Member Name,'));
+ });
+
+ it('formats month cadence correctly', async function () {
+ await service.sendReminder({...reminderData, cadence: 'month', duration: 3});
+
+ sinon.assert.calledWith(mailer.send, sinon.match.has('html', sinon.match('3 months')));
+ });
+ });
});
diff --git a/ghost/core/test/unit/server/services/gifts/gift-service.test.ts b/ghost/core/test/unit/server/services/gifts/gift-service.test.ts
index c51f8016b9f..2724919ccdd 100644
--- a/ghost/core/test/unit/server/services/gifts/gift-service.test.ts
+++ b/ghost/core/test/unit/server/services/gifts/gift-service.test.ts
@@ -3,9 +3,30 @@ import errors from '@tryghost/errors';
import sinon from 'sinon';
import {GiftService, type GiftPurchaseData} from '../../../../../core/server/services/gifts/gift-service';
import {Gift} from '../../../../../core/server/services/gifts/gift';
-import type {GiftRepository} from '../../../../../core/server/services/gifts/gift-repository';
+import type {FindPendingReminderOptions, GiftRepository} from '../../../../../core/server/services/gifts/gift-repository';
import {buildGift} from './utils';
+function buildRedeemedGift(overrides: Parameters[0] = {}) {
+ return buildGift({
+ token: 'gift-token',
+ status: 'redeemed',
+ redeemerMemberId: 'member_1',
+ redeemedAt: new Date('2025-04-01T00:00:00.000Z'),
+ consumesAt: new Date('2026-04-16T00:00:00.000Z'),
+ ...overrides
+ });
+}
+
+function buildRedeemer(id: string = 'member_1') {
+ const memberGet = sinon.stub();
+
+ memberGet.withArgs('email').returns(`${id}@example.com`);
+ memberGet.withArgs('name').returns('Member Name');
+ memberGet.withArgs('email_disabled').returns(false);
+
+ return {id, get: memberGet};
+}
+
describe('GiftService', function () {
type GiftRepositoryStub = {
existsByCheckoutSessionId: sinon.SinonStub<[string], Promise>;
@@ -13,6 +34,7 @@ describe('GiftService', function () {
getByPaymentIntentId: sinon.SinonStub<[string], Promise>;
findPendingConsumption: sinon.SinonStub<[], Promise>;
findPendingExpiration: sinon.SinonStub<[], Promise>;
+ findPendingReminder: sinon.SinonStub<[FindPendingReminderOptions], Promise>;
create: sinon.SinonStub;
update: sinon.SinonStub;
transaction: sinon.SinonStub, Promise>;
@@ -29,6 +51,7 @@ describe('GiftService', function () {
};
let giftEmailService: {
sendPurchaseConfirmation: sinon.SinonStub;
+ sendReminder: sinon.SinonStub;
};
let tiersService: {
api: {
@@ -55,6 +78,7 @@ describe('GiftService', function () {
getByPaymentIntentId: sinon.stub<[string], Promise>().resolves(null),
findPendingConsumption: sinon.stub<[], Promise>().resolves([]),
findPendingExpiration: sinon.stub<[], Promise>().resolves([]),
+ findPendingReminder: sinon.stub<[FindPendingReminderOptions], Promise>().resolves([]),
create: sinon.stub(),
update: sinon.stub(),
transaction: sinon.stub, Promise>().callsFake(async (callback) => {
@@ -70,7 +94,8 @@ describe('GiftService', function () {
notifyGiftSubscriptionStarted: sinon.stub()
};
giftEmailService = {
- sendPurchaseConfirmation: sinon.stub()
+ sendPurchaseConfirmation: sinon.stub().resolves(undefined),
+ sendReminder: sinon.stub().resolves(undefined)
};
tiersService = {
api: {
@@ -295,18 +320,14 @@ describe('GiftService', function () {
assert.equal(result, expectedGift);
});
- it('throws NotFoundError when the token does not exist', async function () {
+ it('returns null when the token does not exist', async function () {
giftRepository.getByToken.resolves(null);
const service = createService();
- await assert.rejects(
- () => service.getByToken('missing-token'),
- (err: any) => {
- assert.equal(err.errorType, 'NotFoundError');
- assert.equal(err.message, 'This gift does not exist.');
- return true;
- }
- );
+ const result = await service.getByToken('missing-token');
+
+ sinon.assert.calledOnceWithExactly(giftRepository.getByToken, 'missing-token');
+ assert.equal(result, null);
});
});
@@ -661,6 +682,271 @@ describe('GiftService', function () {
});
});
+ describe('processReminders', function () {
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
+
+ it('returns zero counts when no gifts are pending reminders', async function () {
+ giftRepository.findPendingReminder.resolves([]);
+
+ const service = createService();
+ const result = await service.processReminders();
+
+ assert.deepEqual(result, {remindedCount: 0, skippedCount: 0, failedCount: 0});
+ sinon.assert.notCalled(giftEmailService.sendReminder);
+ sinon.assert.notCalled(giftRepository.update);
+ });
+
+ it('queries the repository with the 7d/3d window', async function () {
+ giftRepository.findPendingReminder.resolves([]);
+
+ const before = Date.now();
+ const service = createService();
+ await service.processReminders();
+ const after = Date.now();
+
+ sinon.assert.calledOnce(giftRepository.findPendingReminder);
+
+ const args = giftRepository.findPendingReminder.getCall(0).args[0];
+
+ assert.equal(args.reminderLeadMs, 7 * MS_PER_DAY);
+ assert.equal(args.reminderFloorMs, 3 * MS_PER_DAY);
+ assert.ok(args.now.getTime() >= before);
+ assert.ok(args.now.getTime() <= after);
+ });
+
+ it('sends the reminder, marks the gift as reminded, and returns counts', async function () {
+ const gift = buildRedeemedGift();
+
+ giftRepository.findPendingReminder.resolves([gift]);
+ giftRepository.getByToken.resolves(gift);
+ memberRepository.get.resolves(buildRedeemer());
+
+ const service = createService();
+ const result = await service.processReminders();
+
+ assert.equal(result.remindedCount, 1);
+ assert.equal(result.skippedCount, 0);
+ assert.equal(result.failedCount, 0);
+
+ sinon.assert.calledOnce(giftRepository.transaction);
+
+ // getByToken is called twice: once unlocked (before the tier check) and
+ // once locked (inside the transaction).
+ assert.equal(giftRepository.getByToken.callCount, 2);
+ sinon.assert.calledWithExactly(giftRepository.getByToken.firstCall, gift.token);
+ sinon.assert.calledWithExactly(giftRepository.getByToken.secondCall, gift.token, {transacting: 'trx', forUpdate: true});
+
+ sinon.assert.calledOnceWithExactly(memberRepository.get, {id: 'member_1'}, {transacting: 'trx', forUpdate: true});
+
+ sinon.assert.calledOnce(giftEmailService.sendReminder);
+
+ const emailArgs = giftEmailService.sendReminder.getCall(0).args[0];
+
+ assert.equal(emailArgs.memberEmail, 'member_1@example.com');
+ assert.equal(emailArgs.memberName, 'Member Name');
+ assert.equal(emailArgs.tierName, 'Bronze');
+ assert.equal(emailArgs.cadence, gift.cadence);
+ assert.equal(emailArgs.duration, gift.duration);
+ assert.equal(emailArgs.consumesAt, gift.consumesAt);
+
+ sinon.assert.calledOnce(giftRepository.update);
+
+ const savedGift = giftRepository.update.getCall(0).args[0];
+
+ assert.notEqual(savedGift.consumesSoonReminderSentAt, null);
+ });
+
+ it('skips gifts no longer in redeemed status when re-loaded', async function () {
+ const gift = buildRedeemedGift();
+
+ giftRepository.findPendingReminder.resolves([gift]);
+ giftRepository.getByToken.resolves(buildGift({
+ status: 'refunded',
+ refundedAt: new Date()
+ }));
+
+ const service = createService();
+ const result = await service.processReminders();
+
+ assert.equal(result.remindedCount, 0);
+ assert.equal(result.skippedCount, 1);
+ sinon.assert.notCalled(giftEmailService.sendReminder);
+ sinon.assert.notCalled(giftRepository.update);
+ });
+
+ it('skips gifts that have already been reminded', async function () {
+ const gift = buildRedeemedGift({
+ consumesSoonReminderSentAt: new Date('2026-04-10T00:00:00.000Z')
+ });
+
+ giftRepository.findPendingReminder.resolves([gift]);
+ giftRepository.getByToken.resolves(gift);
+
+ const service = createService();
+ const result = await service.processReminders();
+
+ assert.equal(result.remindedCount, 0);
+ assert.equal(result.skippedCount, 1);
+ sinon.assert.notCalled(giftEmailService.sendReminder);
+ sinon.assert.notCalled(giftRepository.update);
+ });
+
+ it('marks the gift as reminded but does not send when the redeemer has email_disabled', async function () {
+ const gift = buildRedeemedGift();
+ const memberGet = sinon.stub();
+
+ memberGet.withArgs('email').returns('member@example.com');
+ memberGet.withArgs('name').returns('Member Name');
+ memberGet.withArgs('email_disabled').returns(true);
+
+ giftRepository.findPendingReminder.resolves([gift]);
+ giftRepository.getByToken.resolves(gift);
+ memberRepository.get.resolves({id: 'member_1', get: memberGet});
+
+ const service = createService();
+ const result = await service.processReminders();
+
+ assert.equal(result.remindedCount, 0);
+ assert.equal(result.skippedCount, 1);
+ sinon.assert.notCalled(giftEmailService.sendReminder);
+ sinon.assert.calledOnce(giftRepository.update);
+
+ const savedGift = giftRepository.update.getCall(0).args[0];
+
+ assert.notEqual(savedGift.consumesSoonReminderSentAt, null);
+ });
+
+ it('marks the gift as reminded but does not send when the redeemer no longer exists', async function () {
+ const gift = buildRedeemedGift();
+
+ giftRepository.findPendingReminder.resolves([gift]);
+ giftRepository.getByToken.resolves(gift);
+ memberRepository.get.resolves(null);
+
+ const service = createService();
+ const result = await service.processReminders();
+
+ assert.equal(result.remindedCount, 0);
+ assert.equal(result.skippedCount, 1);
+ sinon.assert.notCalled(giftEmailService.sendReminder);
+ sinon.assert.calledOnce(giftRepository.update);
+
+ const savedGift = giftRepository.update.getCall(0).args[0];
+
+ assert.notEqual(savedGift.consumesSoonReminderSentAt, null);
+ });
+
+ it('marks the gift as reminded before sending so a failed email does not cause a duplicate send on retry', async function () {
+ // Mark-before-send trade: we accept the risk of a missed reminder on
+ // email failure in exchange for the guarantee that no gift is ever
+ // reminded twice. The failure is caught by processReminders'
+ // per-gift try/catch and counted as a failure rather than propagated.
+ const gift = buildRedeemedGift();
+
+ giftRepository.findPendingReminder.resolves([gift]);
+ giftRepository.getByToken.resolves(gift);
+ memberRepository.get.resolves(buildRedeemer());
+ giftEmailService.sendReminder.rejects(new Error('SMTP error'));
+
+ const service = createService();
+ const result = await service.processReminders();
+
+ assert.equal(result.remindedCount, 0);
+ assert.equal(result.skippedCount, 0);
+ assert.equal(result.failedCount, 1);
+
+ // The reminder-sent marker was committed before the email was attempted.
+ sinon.assert.calledOnce(giftRepository.update);
+
+ const marked = giftRepository.update.getCall(0).args[0];
+ assert.notEqual(marked.consumesSoonReminderSentAt, null);
+
+ // And the update call finished before sendReminder was invoked.
+ sinon.assert.callOrder(giftRepository.update, giftEmailService.sendReminder);
+ });
+
+ it('does not mark the gift as reminded when the tier is missing so an admin fix recovers the reminder', async function () {
+ const gift = buildRedeemedGift();
+
+ giftRepository.findPendingReminder.resolves([gift]);
+ giftRepository.getByToken.resolves(gift);
+ tiersService.api.read.resolves(null);
+
+ const service = createService();
+ const result = await service.processReminders();
+
+ assert.equal(result.remindedCount, 0);
+ assert.equal(result.skippedCount, 0);
+ assert.equal(result.failedCount, 1);
+
+ // Tier is read up front, but the transaction never runs, so the gift
+ // is neither locked nor marked as reminded. A follow-up run after the
+ // tier is restored will pick the gift up again.
+ sinon.assert.notCalled(giftRepository.update);
+ sinon.assert.notCalled(giftEmailService.sendReminder);
+ });
+
+ it('continues processing the batch when one gift fails', async function () {
+ // Gift 1 will fail at the email stage; gift 2 should still be processed.
+ const gift1 = buildRedeemedGift({token: 'gift-1', redeemerMemberId: 'member_1'});
+ const gift2 = buildRedeemedGift({token: 'gift-2', redeemerMemberId: 'member_2'});
+
+ giftRepository.findPendingReminder.resolves([gift1, gift2]);
+
+ // getByToken resolves regardless of whether the lock options are passed.
+ giftRepository.getByToken
+ .withArgs('gift-1').resolves(gift1)
+ .withArgs('gift-1', sinon.match.any).resolves(gift1)
+ .withArgs('gift-2').resolves(gift2)
+ .withArgs('gift-2', sinon.match.any).resolves(gift2);
+
+ memberRepository.get
+ .withArgs({id: 'member_1'}, sinon.match.any).resolves(buildRedeemer('member_1'))
+ .withArgs({id: 'member_2'}, sinon.match.any).resolves(buildRedeemer('member_2'));
+
+ giftEmailService.sendReminder
+ .onFirstCall().rejects(new Error('Transient SMTP error'))
+ .onSecondCall().resolves(undefined);
+
+ const service = createService();
+ const result = await service.processReminders();
+
+ assert.equal(result.remindedCount, 1);
+ assert.equal(result.skippedCount, 0);
+ assert.equal(result.failedCount, 1);
+
+ // Both gifts were claimed (marked as reminded inside their transactions),
+ // and both emails were attempted.
+ assert.equal(giftRepository.update.callCount, 2);
+ assert.equal(giftEmailService.sendReminder.callCount, 2);
+ });
+
+ it('handles multiple gifts independently', async function () {
+ const gift1 = buildRedeemedGift({token: 'gift-1', redeemerMemberId: 'member_1'});
+ const gift2 = buildRedeemedGift({token: 'gift-2', redeemerMemberId: 'member_2'});
+
+ giftRepository.findPendingReminder.resolves([gift1, gift2]);
+ giftRepository.getByToken
+ .withArgs('gift-1').resolves(gift1)
+ .withArgs('gift-1', sinon.match.any).resolves(gift1)
+ .withArgs('gift-2').resolves(gift2)
+ .withArgs('gift-2', sinon.match.any).resolves(gift2);
+
+ memberRepository.get
+ .withArgs({id: 'member_1'}, sinon.match.any).resolves(buildRedeemer('member_1'))
+ .withArgs({id: 'member_2'}, sinon.match.any).resolves(buildRedeemer('member_2'));
+
+ const service = createService();
+ const result = await service.processReminders();
+
+ assert.equal(result.remindedCount, 2);
+ assert.equal(result.skippedCount, 0);
+ assert.equal(result.failedCount, 0);
+ assert.equal(giftEmailService.sendReminder.callCount, 2);
+ assert.equal(giftRepository.update.callCount, 2);
+ });
+ });
+
describe('redeem', function () {
it('redeems the gift, saves it, and grants gift access to the member', async function () {
const gift = buildGift();
diff --git a/ghost/core/test/unit/server/services/gifts/gift.test.ts b/ghost/core/test/unit/server/services/gifts/gift.test.ts
index 4ece9c11e3a..1725257519d 100644
--- a/ghost/core/test/unit/server/services/gifts/gift.test.ts
+++ b/ghost/core/test/unit/server/services/gifts/gift.test.ts
@@ -51,6 +51,7 @@ describe('Gift', function () {
assert.equal(gift.consumedAt, null);
assert.equal(gift.expiredAt, null);
assert.equal(gift.refundedAt, null);
+ assert.equal(gift.consumesSoonReminderSentAt, null);
});
it('passes through purchase data', function () {
@@ -310,4 +311,41 @@ describe('Gift', function () {
assert.equal(result, null);
});
});
+
+ describe('remind', function () {
+ it('returns a gift with consumesSoonReminderSentAt set without mutating the original', function () {
+ const gift = buildGift({
+ status: 'redeemed',
+ redeemerMemberId: 'member_2',
+ redeemedAt: new Date('2026-04-11T12:00:00.000Z'),
+ consumesAt: new Date('2027-04-11T12:00:00.000Z')
+ });
+ const before = new Date();
+
+ const reminded = gift.remind();
+
+ const after = new Date();
+
+ assert.ok(reminded);
+ assert.notEqual(reminded, gift);
+ assert.equal(gift.consumesSoonReminderSentAt, null);
+ assert.ok(reminded.consumesSoonReminderSentAt);
+ assert.ok(reminded.consumesSoonReminderSentAt >= before);
+ assert.ok(reminded.consumesSoonReminderSentAt <= after);
+ });
+
+ it('returns null if already reminded', function () {
+ const gift = buildGift({
+ status: 'redeemed',
+ redeemerMemberId: 'member_2',
+ redeemedAt: new Date('2026-04-11T12:00:00.000Z'),
+ consumesAt: new Date('2027-04-11T12:00:00.000Z'),
+ consumesSoonReminderSentAt: new Date('2027-04-01T12:00:00.000Z')
+ });
+
+ const result = gift.remind();
+
+ assert.equal(result, null);
+ });
+ });
});
diff --git a/ghost/core/test/unit/server/services/gifts/utils.ts b/ghost/core/test/unit/server/services/gifts/utils.ts
index de8e333ffba..d45d0866dfe 100644
--- a/ghost/core/test/unit/server/services/gifts/utils.ts
+++ b/ghost/core/test/unit/server/services/gifts/utils.ts
@@ -21,6 +21,7 @@ export function buildGift(overrides: Partial[
consumedAt: null,
expiredAt: null,
refundedAt: null,
+ consumesSoonReminderSentAt: null,
...overrides
});
}
diff --git a/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js b/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js
index 47bcf4f7606..6242378d910 100644
--- a/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js
+++ b/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js
@@ -535,4 +535,127 @@ describe('EventRepository', function () {
assert.equal(event.data.member_id, null);
});
});
+
+ describe('getGiftRedemptionEvents', function () {
+ let eventRepository;
+ let fake;
+
+ before(function () {
+ fake = sinon.fake.returns({data: [{
+ toJSON: () => ({
+ id: 'gift123',
+ redeemer_member_id: 'member789',
+ redeemer: {id: 'member789', name: 'Test Redeemer', email: 'redeemer@example.com'},
+ tier: {name: 'Gold'},
+ amount: 5000,
+ currency: 'usd',
+ cadence: 'year',
+ duration: 1,
+ redeemed_at: '2024-08-20T09:30:00.000Z',
+ token: 'secret-token',
+ stripe_checkout_session_id: 'cs_123',
+ stripe_payment_intent_id: 'pi_123',
+ status: 'redeemed'
+ })
+ }]});
+ eventRepository = new EventRepository({
+ EmailRecipient: null,
+ MemberSubscribeEvent: null,
+ MemberPaymentEvent: null,
+ MemberStatusEvent: null,
+ MemberLoginEvent: null,
+ MemberPaidSubscriptionEvent: null,
+ labsService: null,
+ Gift: {
+ findPage: fake
+ }
+ });
+ });
+
+ afterEach(function () {
+ fake.resetHistory();
+ });
+
+ it('queries with correct options', async function () {
+ await eventRepository.getGiftRedemptionEvents({
+ filter: 'not used',
+ order: 'created_at desc, id desc'
+ }, {
+ type: 'unused'
+ });
+
+ sinon.assert.calledOnceWithMatch(fake, {
+ withRelated: ['redeemer', 'tier'],
+ filter: 'redeemer_member_id:-null+custom:true',
+ order: 'redeemed_at desc, id desc'
+ });
+ });
+
+ it('returns correctly formatted gift_redemption_event', async function () {
+ const result = await eventRepository.getGiftRedemptionEvents({
+ order: 'created_at desc, id desc'
+ }, {});
+
+ assert.equal(result.data.length, 1);
+
+ const event = result.data[0];
+
+ assert.equal(event.type, 'gift_redemption_event');
+ assert.equal(event.data.id, 'gift123');
+ assert.equal(event.data.amount, 5000);
+ assert.equal(event.data.currency, 'usd');
+ assert.equal(event.data.tier_name, 'Gold');
+ assert.equal(event.data.cadence, 'year');
+ assert.equal(event.data.duration, 1);
+ assert.equal(event.data.member_id, 'member789');
+ assert.equal(event.data.created_at, '2024-08-20T09:30:00.000Z');
+ assert.deepEqual(event.data.member, {
+ id: 'member789',
+ name: 'Test Redeemer',
+ email: 'redeemer@example.com'
+ });
+ });
+
+ it('excludes internal fields from event data', async function () {
+ const result = await eventRepository.getGiftRedemptionEvents({}, {});
+
+ const event = result.data[0];
+
+ assert.equal(event.data.token, undefined);
+ assert.equal(event.data.stripe_checkout_session_id, undefined);
+ assert.equal(event.data.stripe_payment_intent_id, undefined);
+ assert.equal(event.data.status, undefined);
+ });
+
+ it('sets member to null when redeemer is not present', async function () {
+ const nullRedeemerFake = sinon.fake.returns({data: [{
+ toJSON: () => ({
+ id: 'gift999',
+ redeemer_member_id: null,
+ redeemer: null,
+ amount: 3000,
+ currency: 'eur',
+ redeemed_at: '2024-09-01T12:00:00.000Z'
+ })
+ }]});
+ const repo = new EventRepository({
+ EmailRecipient: null,
+ MemberSubscribeEvent: null,
+ MemberPaymentEvent: null,
+ MemberStatusEvent: null,
+ MemberLoginEvent: null,
+ MemberPaidSubscriptionEvent: null,
+ labsService: null,
+ Gift: {
+ findPage: nullRedeemerFake
+ }
+ });
+
+ const result = await repo.getGiftRedemptionEvents({}, {});
+ const event = result.data[0];
+
+ assert.equal(event.data.member, null);
+ assert.equal(event.data.member_id, null);
+ });
+ });
});
diff --git a/ghost/core/test/unit/server/web/gift-preview/controller.test.js b/ghost/core/test/unit/server/web/gift-preview/controller.test.js
new file mode 100644
index 00000000000..883cc7dc510
--- /dev/null
+++ b/ghost/core/test/unit/server/web/gift-preview/controller.test.js
@@ -0,0 +1,220 @@
+const assert = require('node:assert/strict');
+const sinon = require('sinon');
+
+const labs = require('../../../../../core/shared/labs');
+const urlUtils = require('../../../../../core/shared/url-utils');
+const settingsCache = require('../../../../../core/shared/settings-cache');
+const giftServiceWrapper = require('../../../../../core/server/services/gifts');
+const tiersService = require('../../../../../core/server/services/tiers');
+
+const controller = require('../../../../../core/server/web/gift-preview/controller');
+
+describe('Gift Preview Controller', function () {
+ let req;
+ let res;
+ let originalTiersServiceAPI;
+ let originalGiftService;
+
+ beforeEach(function () {
+ req = {
+ params: {
+ token: 'test-token-123'
+ }
+ };
+ res = {
+ redirect: sinon.stub(),
+ send: sinon.stub(),
+ sendStatus: sinon.stub(),
+ set: sinon.stub()
+ };
+ originalTiersServiceAPI = tiersService.api;
+ originalGiftService = giftServiceWrapper.service;
+
+ sinon.stub(urlUtils, 'getSiteUrl').returns('https://example.com/');
+ sinon.stub(settingsCache, 'get');
+ settingsCache.get.withArgs('title').returns('Test Blog');
+ settingsCache.get.withArgs('accent_color').returns('#FF5733');
+ });
+
+ afterEach(function () {
+ tiersService.api = originalTiersServiceAPI;
+ giftServiceWrapper.service = originalGiftService;
+
+ sinon.restore();
+ });
+
+ describe('giftPreview', function () {
+ it('redirects to homepage when lab flag is disabled', async function () {
+ sinon.stub(labs, 'isSet').withArgs('giftSubscriptions').returns(false);
+
+ await controller.giftPreview(req, res);
+
+ sinon.assert.calledOnce(res.redirect);
+ sinon.assert.calledWith(res.redirect, 302, 'https://example.com/');
+ });
+
+ it('redirects to homepage when gift token is invalid', async function () {
+ sinon.stub(labs, 'isSet').withArgs('giftSubscriptions').returns(true);
+ giftServiceWrapper.service = {
+ getByToken: sinon.stub().rejects(new Error('Not found'))
+ };
+
+ await controller.giftPreview(req, res);
+
+ sinon.assert.calledOnce(res.redirect);
+ sinon.assert.calledWith(res.redirect, 302, 'https://example.com/');
+ });
+
+ it('redirects to homepage when tier lookup fails', async function () {
+ sinon.stub(labs, 'isSet').withArgs('giftSubscriptions').returns(true);
+ giftServiceWrapper.service = {
+ getByToken: sinon.stub().resolves({
+ tierId: 'tier_1',
+ cadence: 'year',
+ duration: 1
+ })
+ };
+ tiersService.api = {
+ read: sinon.stub().rejects(new Error('Tier not found'))
+ };
+
+ await controller.giftPreview(req, res);
+
+ sinon.assert.calledOnce(res.redirect);
+ sinon.assert.calledWith(res.redirect, 302, 'https://example.com/');
+ });
+
+ it('returns HTML with OG tags for a valid gift', async function () {
+ sinon.stub(labs, 'isSet').withArgs('giftSubscriptions').returns(true);
+ giftServiceWrapper.service = {
+ getByToken: sinon.stub().resolves({
+ tierId: 'tier_1',
+ cadence: 'year',
+ duration: 1
+ })
+ };
+ tiersService.api = {
+ read: sinon.stub().resolves({name: 'Gold'})
+ };
+
+ await controller.giftPreview(req, res);
+
+ sinon.assert.calledWith(res.set, 'Cache-Control', 'public, max-age=3600');
+ sinon.assert.calledWith(res.set, 'Content-Type', 'text/html; charset=utf-8');
+ sinon.assert.calledOnce(res.send);
+
+ const html = res.send.firstCall.args[0];
+
+ assert.ok(html.includes(' '));
+ assert.ok(html.includes(' '));
+ assert.ok(html.includes(' '));
+ assert.ok(html.includes(' '));
+ assert.ok(html.includes('content="0;url=https://example.com/#/portal/gift/redeem/test-token-123"'));
+ });
+
+ it('escapes HTML in site title', async function () {
+ sinon.stub(labs, 'isSet').withArgs('giftSubscriptions').returns(true);
+ settingsCache.get.withArgs('title').returns('Blog ');
+ giftServiceWrapper.service = {
+ getByToken: sinon.stub().resolves({
+ tierId: 'tier_1',
+ cadence: 'month',
+ duration: 3
+ })
+ };
+ tiersService.api = {
+ read: sinon.stub().resolves({name: 'Silver'})
+ };
+
+ await controller.giftPreview(req, res);
+
+ const html = res.send.firstCall.args[0];
+
+ assert.ok(!html.includes(''));
+ assert.ok(html.includes('<script>alert("xss")</script>'));
+ });
+
+ it('uses monthly cadence label', async function () {
+ sinon.stub(labs, 'isSet').withArgs('giftSubscriptions').returns(true);
+ giftServiceWrapper.service = {
+ getByToken: sinon.stub().resolves({
+ tierId: 'tier_1',
+ cadence: 'month',
+ duration: 3
+ })
+ };
+ tiersService.api = {
+ read: sinon.stub().resolves({name: 'Bronze'})
+ };
+
+ await controller.giftPreview(req, res);
+
+ const html = res.send.firstCall.args[0];
+
+ assert.ok(html.includes('Bronze \u00B7 3 months'));
+ });
+
+ it('defaults site title to Ghost', async function () {
+ sinon.stub(labs, 'isSet').withArgs('giftSubscriptions').returns(true);
+ settingsCache.get.withArgs('title').returns(null);
+ giftServiceWrapper.service = {
+ getByToken: sinon.stub().resolves({
+ tierId: 'tier_1',
+ cadence: 'year',
+ duration: 1
+ })
+ };
+ tiersService.api = {
+ read: sinon.stub().resolves({name: 'Premium'})
+ };
+
+ await controller.giftPreview(req, res);
+
+ const html = res.send.firstCall.args[0];
+
+ assert.ok(html.includes('A gift membership to Ghost'));
+ });
+ });
+
+ describe('giftPreviewImage', function () {
+ it('returns 404 when lab flag is disabled', async function () {
+ sinon.stub(labs, 'isSet').withArgs('giftSubscriptions').returns(false);
+
+ await controller.giftPreviewImage(req, res);
+
+ sinon.assert.calledOnce(res.sendStatus);
+ sinon.assert.calledWith(res.sendStatus, 404);
+ });
+
+ it('returns 404 when gift token is invalid', async function () {
+ sinon.stub(labs, 'isSet').withArgs('giftSubscriptions').returns(true);
+ giftServiceWrapper.service = {
+ getByToken: sinon.stub().rejects(new Error('Not found'))
+ };
+
+ await controller.giftPreviewImage(req, res);
+
+ sinon.assert.calledOnce(res.sendStatus);
+ sinon.assert.calledWith(res.sendStatus, 404);
+ });
+
+ it('returns 404 when tier lookup fails', async function () {
+ sinon.stub(labs, 'isSet').withArgs('giftSubscriptions').returns(true);
+ giftServiceWrapper.service = {
+ getByToken: sinon.stub().resolves({
+ tierId: 'tier_1',
+ cadence: 'year',
+ duration: 1
+ })
+ };
+ tiersService.api = {
+ read: sinon.stub().rejects(new Error('Tier not found'))
+ };
+
+ await controller.giftPreviewImage(req, res);
+
+ sinon.assert.calledOnce(res.sendStatus);
+ sinon.assert.calledWith(res.sendStatus, 404);
+ });
+ });
+});
diff --git a/ghost/core/test/unit/server/web/gift-preview/image.test.js b/ghost/core/test/unit/server/web/gift-preview/image.test.js
new file mode 100644
index 00000000000..bd83fe5ecab
--- /dev/null
+++ b/ghost/core/test/unit/server/web/gift-preview/image.test.js
@@ -0,0 +1,57 @@
+const assert = require('node:assert/strict');
+const sinon = require('sinon');
+const imageModule = require('../../../../../core/server/web/gift-preview/image');
+
+describe('Gift Preview Image', function () {
+ afterEach(function () {
+ sinon.restore();
+ });
+
+ describe('generateGiftPreviewImage', function () {
+ it('generates a PNG buffer', async function () {
+ const result = await imageModule.generateGiftPreviewImage({
+ tierName: 'Gold',
+ cadenceLabel: '1 year',
+ accentColor: '#FF5733'
+ });
+
+ assert.ok(Buffer.isBuffer(result));
+ assert.ok(result.length > 0);
+
+ // PNG magic bytes
+ assert.equal(result[0], 0x89);
+ assert.equal(result[1], 0x50); // P
+ assert.equal(result[2], 0x4E); // N
+ assert.equal(result[3], 0x47); // G
+ });
+
+ it('returns cached result on second call with same params', async function () {
+ const params = {
+ tierName: 'CacheTest',
+ cadenceLabel: '1 year',
+ accentColor: '#000000'
+ };
+
+ const first = await imageModule.generateGiftPreviewImage(params);
+ const second = await imageModule.generateGiftPreviewImage(params);
+
+ assert.equal(first, second, 'Should return the exact same buffer reference from cache');
+ });
+
+ it('returns different results for different params', async function () {
+ const result1 = await imageModule.generateGiftPreviewImage({
+ tierName: 'Gold',
+ cadenceLabel: '1 year',
+ accentColor: '#FF5733'
+ });
+
+ const result2 = await imageModule.generateGiftPreviewImage({
+ tierName: 'Silver',
+ cadenceLabel: '3 months',
+ accentColor: '#333333'
+ });
+
+ assert.notEqual(result1, result2);
+ });
+ });
+});
diff --git a/ghost/parse-email-address/package.json b/ghost/parse-email-address/package.json
index 6ad4b8c23f9..5f3a717b353 100644
--- a/ghost/parse-email-address/package.json
+++ b/ghost/parse-email-address/package.json
@@ -35,5 +35,12 @@
},
"dependencies": {
"parse-email-address": "0.0.2"
+ },
+ "nx": {
+ "targets": {
+ "build": {
+ "outputs": ["{projectRoot}/build"]
+ }
+ }
}
}