diff --git a/ghost/core/core/server/services/gifts/email-templates/gift-purchase-confirmation.hbs b/ghost/core/core/server/services/gifts/email-templates/gift-purchase-confirmation.hbs new file mode 100644 index 00000000000..2efc6e7ae74 --- /dev/null +++ b/ghost/core/core/server/services/gifts/email-templates/gift-purchase-confirmation.hbs @@ -0,0 +1,104 @@ + + + + + + Your gift subscription confirmation + + + + + + + + + +
  +
+ + + + + + + + + + + +
+ + {{#if siteIconUrl}} + + + + {{/if}} + + + + + + + + + + + + + +
{{siteTitle}}
+

Your gift is ready to share!

+

Share the link below with the recipient to let them redeem their gift membership.

+ + + + + + +
+ + + + +
+

Gift subscription

+

{{gift.tierName}} ({{gift.cadenceLabel}})

+

Amount paid

+

{{gift.amount}}

+
+
+ + + + + + +
+

Redemption link

+

{{gift.link}}

+

This link can be redeemed once and expires on {{gift.expiresAt}}.

+
+
+

This message was sent from {{siteDomain}} to {{toEmail}}

+
+

You received this email because you purchased a gift subscription on {{siteTitle}}.

+
+
+ + + +
+
 
+ + diff --git a/ghost/core/core/server/services/gifts/email-templates/gift-purchase-confirmation.ts b/ghost/core/core/server/services/gifts/email-templates/gift-purchase-confirmation.ts new file mode 100644 index 00000000000..79d891a61e1 --- /dev/null +++ b/ghost/core/core/server/services/gifts/email-templates/gift-purchase-confirmation.ts @@ -0,0 +1,30 @@ +export interface GiftPurchaseConfirmationData { + siteTitle: string; + siteDomain: string; + toEmail: string; + gift: { + amount: string; + tierName: string; + cadenceLabel: string; + link: string; + expiresAt: string; + }; +} + +export function renderText(data: GiftPurchaseConfirmationData): string { + return `Your gift is ready to share! + +Share the link below with the recipient to let them redeem their gift membership. + +Gift subscription: ${data.gift.tierName} (${data.gift.cadenceLabel}) +Amount paid: ${data.gift.amount} + +Redemption link: ${data.gift.link} + +This link can be redeemed once and expires on ${data.gift.expiresAt}. + +--- + +Sent to ${data.toEmail} from ${data.siteDomain}. +You received this email because you purchased a gift subscription on ${data.siteTitle}.`; +} diff --git a/ghost/core/core/server/services/gifts/gift-email-renderer.ts b/ghost/core/core/server/services/gifts/gift-email-renderer.ts new file mode 100644 index 00000000000..08d71ea7e87 --- /dev/null +++ b/ghost/core/core/server/services/gifts/gift-email-renderer.ts @@ -0,0 +1,28 @@ +import {promises as fs} from 'node:fs'; +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'; + +export class GiftEmailRenderer { + private readonly handlebars: typeof Handlebars; + + private purchaseConfirmationTemplate: HandlebarsTemplateDelegate | null = null; + + constructor() { + this.handlebars = Handlebars.create(); + } + + async renderPurchaseConfirmation(data: GiftPurchaseConfirmationData): Promise<{html: string; text: string}> { + if (!this.purchaseConfirmationTemplate) { + const source = await fs.readFile(path.join(__dirname, './email-templates/gift-purchase-confirmation.hbs'), 'utf8'); + + this.purchaseConfirmationTemplate = this.handlebars.compile(source); + } + + return { + html: this.purchaseConfirmationTemplate(data), + text: renderPurchaseConfirmationText(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 new file mode 100644 index 00000000000..8ec400e6c5b --- /dev/null +++ b/ghost/core/core/server/services/gifts/gift-email-service.ts @@ -0,0 +1,115 @@ +import moment from 'moment'; +import {GiftEmailRenderer} from './gift-email-renderer'; + +interface Mailer { + send(message: { + to: string; + subject: string; + html: string; + text: string; + from: string; + forceTextContent: boolean; + }): Promise; +} + +interface SettingsCache { + get(key: string, options?: unknown): string | undefined; +} + +interface UrlUtils { + getSiteUrl(): string; +} + +interface BlogIcon { + getIconUrl(options: {absolute: boolean; fallbackToDefault: boolean}): string | null; +} + +interface PurchaseConfirmationData { + buyerEmail: string; + amount: number; + currency: string; + token: string; + tierName: string; + cadence: 'month' | 'year'; + duration: number; + expiresAt: Date; +} + +export class GiftEmailService { + private readonly mailer: Mailer; + private readonly settingsCache: SettingsCache; + private readonly urlUtils: UrlUtils; + private readonly getFromAddress: () => string; + private readonly blogIcon: BlogIcon; + private readonly renderer: GiftEmailRenderer; + + constructor({mailer, settingsCache, urlUtils, getFromAddress, blogIcon}: {mailer: Mailer; settingsCache: SettingsCache; urlUtils: UrlUtils; getFromAddress: () => string; blogIcon: BlogIcon}) { + this.mailer = mailer; + this.settingsCache = settingsCache; + this.urlUtils = urlUtils; + this.getFromAddress = getFromAddress; + this.blogIcon = blogIcon; + + this.renderer = new GiftEmailRenderer(); + } + + async sendPurchaseConfirmation({buyerEmail, amount, currency, token, tierName, cadence, duration, expiresAt}: PurchaseConfirmationData): Promise { + const formattedAmount = this.formatAmount({currency, amount: amount / 100}); + const siteDomain = this.siteDomain; + const siteUrl = this.urlUtils.getSiteUrl(); + + const giftLink = `${siteUrl.replace(/\/$/, '')}/gift/${token}`; + + const unit = cadence === 'month' ? 'month' : 'year'; + const cadenceLabel = duration === 1 ? `1 ${unit}` : `${duration} ${unit}s`; + + const templateData = { + siteTitle: this.settingsCache.get('title') ?? siteDomain, + siteUrl, + siteIconUrl: this.blogIcon.getIconUrl({absolute: true, fallbackToDefault: false}), + siteDomain, + accentColor: this.settingsCache.get('accent_color'), + toEmail: buyerEmail, + gift: { + amount: formattedAmount, + tierName, + cadenceLabel, + link: giftLink, + expiresAt: moment(expiresAt).format('D MMM YYYY') + } + }; + + const {html, text} = await this.renderer.renderPurchaseConfirmation(templateData); + + await this.mailer.send({ + to: buyerEmail, + subject: 'Gift subscription purchase confirmation', + html, + text, + from: this.getFromAddress(), + forceTextContent: true + }); + } + + private get siteDomain(): string { + try { + return new URL(this.urlUtils.getSiteUrl()).hostname; + } catch { + return ''; + } + } + + private formatAmount({amount = 0, currency}: {amount?: number; currency?: string}): string { + if (!currency) { + return Intl.NumberFormat('en', {maximumFractionDigits: 2}).format(amount); + } + + return Intl.NumberFormat('en', { + style: 'currency', + currency, + currencyDisplay: 'symbol', + maximumFractionDigits: 2, + minimumFractionDigits: 2 + }).format(amount); + } +} diff --git a/ghost/core/core/server/services/gifts/gift-service-wrapper.js b/ghost/core/core/server/services/gifts/gift-service-wrapper.js index 77d350e95dd..23076bb8d0c 100644 --- a/ghost/core/core/server/services/gifts/gift-service-wrapper.js +++ b/ghost/core/core/server/services/gifts/gift-service-wrapper.js @@ -9,18 +9,37 @@ class GiftServiceWrapper { const {Gift: GiftModel} = require('../../models'); const {GiftBookshelfRepository} = require('./gift-bookshelf-repository'); const {GiftService} = require('./gift-service'); + const {GiftEmailService} = require('./gift-email-service'); const membersService = require('../members'); + const tiersService = require('../tiers/service'); const staffService = require('../staff'); + const {GhostMailer} = require('../mail'); + const settingsCache = require('../../../shared/settings-cache'); + const urlUtils = require('../../../shared/url-utils'); + const settingsHelpers = require('../settings-helpers'); + const EmailAddressParser = require('../email-address/email-address-parser'); + const {blogIcon} = require('../../../server/lib/image'); + const repository = new GiftBookshelfRepository({ GiftModel }); + const giftEmailService = new GiftEmailService({ + mailer: new GhostMailer(), + settingsCache, + urlUtils, + getFromAddress: () => EmailAddressParser.stringify(settingsHelpers.getDefaultEmail()), + blogIcon + }); + this.service = new GiftService({ giftRepository: repository, get memberRepository() { return membersService.api.members; }, + tiersService, + giftEmailService, get staffServiceEmails() { return staffService.api.emails; } diff --git a/ghost/core/core/server/services/gifts/gift-service.ts b/ghost/core/core/server/services/gifts/gift-service.ts index 0d7fa73c93c..20c66b670af 100644 --- a/ghost/core/core/server/services/gifts/gift-service.ts +++ b/ghost/core/core/server/services/gifts/gift-service.ts @@ -7,8 +7,33 @@ interface MemberRepository { get(filter: Record): Promise<{id: string; get(key: string): string | null} | null>; } +interface TiersService { + api: { + read(idString: string): Promise<{name: string} | null>; + }; +} + +interface GiftEmailService { + sendPurchaseConfirmation(data: { + buyerEmail: string; + amount: number; + currency: string; + token: string; + tierName: string; + cadence: 'month' | 'year'; + duration: number; + expiresAt: Date; + }): Promise; +} + interface StaffServiceEmails { - notifyGiftReceived(data: {name: string | null; email: string; memberId: string | null; amount: number; currency: string}): Promise; + notifyGiftReceived(data: { + name: string | null; + email: string; + memberId: string | null; + amount: number; + currency: string; + }): Promise; } interface GiftPurchaseData { @@ -27,11 +52,15 @@ interface GiftPurchaseData { export class GiftService { readonly #giftRepository: GiftBookshelfRepository; readonly #memberRepository: MemberRepository; + readonly #tiersService: TiersService; + readonly #giftEmailService: GiftEmailService; readonly #staffServiceEmails: StaffServiceEmails; - constructor({giftRepository, memberRepository, staffServiceEmails}: {giftRepository: GiftBookshelfRepository; memberRepository: MemberRepository; staffServiceEmails: StaffServiceEmails}) { + constructor({giftRepository, memberRepository, tiersService, giftEmailService, staffServiceEmails}: {giftRepository: GiftBookshelfRepository; memberRepository: MemberRepository; tiersService: TiersService; giftEmailService: GiftEmailService; staffServiceEmails: StaffServiceEmails}) { this.#giftRepository = giftRepository; this.#memberRepository = memberRepository; + this.#tiersService = tiersService; + this.#giftEmailService = giftEmailService; this.#staffServiceEmails = staffServiceEmails; } @@ -77,6 +106,31 @@ export class GiftService { logging.error(err); } + try { + const tier = await this.#tiersService.api.read(data.tierId); + + if (!tier) { + throw new errors.NotFoundError({message: `Tier not found: ${data.tierId}`}); + } + + if (!gift.expiresAt) { + throw new errors.InternalServerError({message: 'Gift is missing expiration date'}); + } + + await this.#giftEmailService.sendPurchaseConfirmation({ + buyerEmail: data.buyerEmail, + amount: data.amount, + currency: data.currency, + token: data.token, + tierName: tier.name, + cadence: data.cadence, + duration, + expiresAt: gift.expiresAt + }); + } catch (err) { + logging.error(err); + } + return true; } } diff --git a/ghost/core/core/server/services/members/members-api/services/payments-service.js b/ghost/core/core/server/services/members/members-api/services/payments-service.js index 96bbd30b866..ed18b84c8dc 100644 --- a/ghost/core/core/server/services/members/members-api/services/payments-service.js +++ b/ghost/core/core/server/services/members/members-api/services/payments-service.js @@ -193,7 +193,7 @@ class PaymentsService { tier_id: tier.id.toHexString(), cadence, duration: String(duration), - purchaser_email: email + buyer_email: email }, successUrl: successUrlObj.toString(), cancelUrl, diff --git a/ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js b/ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js index 13b6904ad7e..bb38e51d42d 100644 --- a/ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js +++ b/ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js @@ -66,7 +66,7 @@ module.exports = class CheckoutSessionEventService { async handleGiftEvent(session) { await this.deps.giftService.recordPurchase({ token: session.metadata?.gift_token, - buyerEmail: session.metadata?.purchaser_email, + buyerEmail: session.metadata?.buyer_email, stripeCustomerId: session.customer ?? null, tierId: session.metadata?.tier_id, cadence: session.metadata?.cadence, 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 new file mode 100644 index 00000000000..ba2f3fbdf10 --- /dev/null +++ b/ghost/core/test/unit/server/services/gifts/gift-email-service.test.js @@ -0,0 +1,115 @@ +const sinon = require('sinon'); +const {GiftEmailService} = require('../../../../../core/server/services/gifts/gift-email-service'); + +describe('GiftEmailService', function () { + let mailer; + let service; + + const settingsCache = { + get: (key) => { + if (key === 'title') { + return 'Test Site'; + } + if (key === 'accent_color') { + return '#ff5500'; + } + + return ''; + } + }; + + const urlUtils = { + getSiteUrl: () => 'https://example.com/' + }; + + const getFromAddress = () => 'Test Site '; + + const blogIcon = { + getIconUrl: () => 'https://example.com/icon.png' + }; + + const defaultData = { + buyerEmail: 'buyer@example.com', + amount: 5000, + currency: 'usd', + token: 'abc-123', + tierName: 'Gold', + cadence: 'year', + duration: 1, + expiresAt: new Date('2027-04-07') + }; + + beforeEach(function () { + mailer = {send: sinon.stub().resolves()}; + service = new GiftEmailService({mailer, settingsCache, urlUtils, getFromAddress, blogIcon}); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('sends to the buyer email with correct subject and from address', async function () { + await service.sendPurchaseConfirmation(defaultData); + + sinon.assert.calledOnce(mailer.send); + sinon.assert.calledWith(mailer.send, sinon.match({ + to: 'buyer@example.com', + subject: 'Gift subscription purchase confirmation', + from: 'Test Site ' + })); + }); + + it('includes gift link, tier name, and cadence in both HTML and text', async function () { + await service.sendPurchaseConfirmation(defaultData); + + const msg = mailer.send.getCall(0).args[0]; + + for (const field of ['html', 'text']) { + sinon.assert.match(msg[field], sinon.match('https://example.com/gift/abc-123')); + sinon.assert.match(msg[field], sinon.match('Gold')); + sinon.assert.match(msg[field], sinon.match('1 year')); + } + }); + + it('includes formatted amount in both HTML and text', async function () { + await service.sendPurchaseConfirmation(defaultData); + + const msg = mailer.send.getCall(0).args[0]; + + for (const field of ['html', 'text']) { + sinon.assert.match(msg[field], sinon.match('$50.00')); + } + }); + + it('formats non-USD currency correctly', async function () { + await service.sendPurchaseConfirmation({...defaultData, amount: 1500, currency: 'eur'}); + + sinon.assert.calledWith(mailer.send, sinon.match.has('html', sinon.match('€15.00'))); + }); + + it('formats month cadence correctly', async function () { + await service.sendPurchaseConfirmation({...defaultData, cadence: 'month'}); + + sinon.assert.calledWith(mailer.send, sinon.match.has('html', sinon.match('1 month'))); + }); + + it('falls back to site domain when site title is undefined', async function () { + const noTitleSettingsCache = { + get: (key) => { + if (key === 'title') { + return undefined; + } + if (key === 'accent_color') { + return '#ff5500'; + } + + return ''; + } + }; + + const noTitleService = new GiftEmailService({mailer, settingsCache: noTitleSettingsCache, urlUtils, getFromAddress, blogIcon}); + await noTitleService.sendPurchaseConfirmation(defaultData); + + sinon.assert.calledWith(mailer.send, sinon.match.has('text', sinon.match('gift subscription on example.com'))); + }); +}); 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 00373cf57a6..54671f7f3b7 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 @@ -14,6 +14,12 @@ describe('GiftService', function () { let staffServiceEmails: { notifyGiftReceived: sinon.SinonStub; }; + let giftEmailService: { + sendPurchaseConfirmation: sinon.SinonStub; + }; + let tiersService: { + api: {read: sinon.SinonStub}; + }; const purchaseData = { token: 'abc-123', buyerEmail: 'buyer@example.com', @@ -38,6 +44,12 @@ describe('GiftService', function () { staffServiceEmails = { notifyGiftReceived: sinon.stub() }; + giftEmailService = { + sendPurchaseConfirmation: sinon.stub() + }; + tiersService = { + api: {read: sinon.stub().resolves({name: 'Gold'})} + }; }); afterEach(function () { @@ -45,7 +57,7 @@ describe('GiftService', function () { }); function createService() { - return new GiftService({giftRepository: giftRepository as any, memberRepository, staffServiceEmails}); + return new GiftService({giftRepository: giftRepository as any, memberRepository, tiersService, giftEmailService, staffServiceEmails}); } describe('recordPurchase', function () { @@ -168,7 +180,7 @@ describe('GiftService', function () { assert.equal(emailData.currency, 'usd'); }); - it('uses buyerEmail and null name when purchaser is not a member', async function () { + it('uses buyerEmail and null name when buyer is not a member', async function () { const service = createService(); await service.recordPurchase({...purchaseData, stripeCustomerId: null}); @@ -181,5 +193,48 @@ describe('GiftService', function () { assert.equal(emailData.email, 'buyer@example.com'); assert.equal(emailData.memberId, null); }); + + it('sends buyer confirmation email', async function () { + const service = createService(); + + await service.recordPurchase(purchaseData); + + sinon.assert.calledOnce(tiersService.api.read); + sinon.assert.calledWith(tiersService.api.read, 'tier_1'); + sinon.assert.calledOnce(giftEmailService.sendPurchaseConfirmation); + + const emailData = giftEmailService.sendPurchaseConfirmation.getCall(0).args[0]; + + assert.equal(emailData.buyerEmail, 'buyer@example.com'); + assert.equal(emailData.amount, 5000); + assert.equal(emailData.currency, 'usd'); + assert.equal(emailData.token, 'abc-123'); + assert.equal(emailData.tierName, 'Gold'); + assert.equal(emailData.cadence, 'year'); + assert.equal(emailData.duration, 1); + assert.ok(emailData.expiresAt instanceof Date); + }); + + it('does not send confirmation email when tier is not found', async function () { + tiersService.api.read.resolves(null); + + const service = createService(); + + const result = await service.recordPurchase(purchaseData); + + assert.equal(result, true); + sinon.assert.notCalled(giftEmailService.sendPurchaseConfirmation); + }); + + it('does not fail purchase when buyer confirmation email throws', async function () { + giftEmailService.sendPurchaseConfirmation.rejects(new Error('SMTP error')); + + const service = createService(); + + const result = await service.recordPurchase(purchaseData); + + assert.equal(result, true); + sinon.assert.calledOnce(giftRepository.create); + }); }); }); diff --git a/ghost/core/test/unit/server/services/members/members-api/services/payments-service.test.js b/ghost/core/test/unit/server/services/members/members-api/services/payments-service.test.js index d59f4ca51b8..489d8bd3e06 100644 --- a/ghost/core/test/unit/server/services/members/members-api/services/payments-service.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/services/payments-service.test.js @@ -360,7 +360,7 @@ describe('PaymentsService', function () { assert.equal(args.metadata.tier_id, tier.id.toHexString()); assert.equal(args.metadata.cadence, 'month'); assert.equal(args.metadata.duration, '1'); - assert.equal(args.metadata.purchaser_email, 'buyer@example.com'); + assert.equal(args.metadata.buyer_email, 'buyer@example.com'); assert.equal(args.metadata.requestSrc, 'portal'); assert.match(args.metadata.gift_token, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); }); diff --git a/ghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js b/ghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js index 6ca01d0eb91..3860af532c6 100644 --- a/ghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js +++ b/ghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js @@ -580,7 +580,7 @@ describe('CheckoutSessionEventService', function () { tier_id: 'tier_456', cadence: 'year', duration: '1', - purchaser_email: 'buyer@example.com' + buyer_email: 'buyer@example.com' } }; @@ -617,7 +617,7 @@ describe('CheckoutSessionEventService', function () { tier_id: 'tier_111', cadence: 'month', duration: '1', - purchaser_email: 'guest@example.com' + buyer_email: 'guest@example.com' } }; diff --git a/ghost/core/test/unit/server/services/stripe/stripe-api.test.js b/ghost/core/test/unit/server/services/stripe/stripe-api.test.js index a1c9947bd97..c010d330193 100644 --- a/ghost/core/test/unit/server/services/stripe/stripe-api.test.js +++ b/ghost/core/test/unit/server/services/stripe/stripe-api.test.js @@ -749,7 +749,7 @@ describe('StripeAPI', function () { gift_token: 'token-xyz', tier_id: 'tier_123', cadence: 'month', - purchaser_email: 'buyer@example.com' + buyer_email: 'buyer@example.com' }; await api.createGiftCheckoutSession({