Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Your gift subscription confirmation</title>
<style>
@media only screen and (max-width: 620px) {
table.body h1 { font-size: 22px !important; padding-bottom: 16px !important; }
table.body p, table.body td, table.body a { font-size: 16px !important; }
table.body .wrapper { padding: 10px !important; }
table.body .content { padding: 0 !important; }
table.body .container { padding: 0 !important; width: 100% !important; }
table.body .main { border-radius: 0 !important; }
table.body p.large, table.body p.large a { font-size: 18px !important; }
table.body p.small, table.body a.small { font-size: 12px !important; }
}
</style>
</head>
<body style="background-color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.5em; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;">
<table border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">&nbsp;</td>
<td class="container" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; max-width: 540px; padding: 10px; width: 540px;">
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 600px; padding: 30px 20px;">

<!-- START CENTERED CONTAINER -->

<table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 8px;">

<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; box-sizing: border-box;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
{{#if siteIconUrl}}
<tr>
<td align="center" style="padding-bottom: 56px; text-align: center;"><a href="{{siteUrl}}"><img src="{{siteIconUrl}}" alt="{{siteTitle}}" border="0" width="48" height="48"></a></td>
</tr>
{{/if}}
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">
<h1 style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 26px; color: #15212A; font-weight: bold; line-height: 28px; margin: 0; padding-bottom: 12px;">Your gift is ready to&nbsp;share!</h1>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #738A94; font-weight: normal; margin: 0; padding-bottom: 24px;">Share the link below with the recipient to let them redeem their gift membership.</p>
<table width="100%" border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; table-layout: fixed; width: 100%; min-width: 100%; box-sizing: border-box; background: #F4F5F6; border-radius: 8px;">
<tbody>
<tr>
<td align="left" style="padding: 24px;">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td style="padding-right: 8px; background-color: #F4F5F6; text-align: left; vertical-align: middle;" valign="middle">
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; margin: 0; padding-bottom: 4px; color: #15171A; font-weight: 700;">Gift subscription</p>
<p class="large" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; margin: 0; padding-bottom: 24px; color: #15171A; font-weight: 400;">{{gift.tierName}} ({{gift.cadenceLabel}})</p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; margin: 0; padding-bottom: 4px; color: #15171A; font-weight: 700;">Amount paid</p>
<p class="large" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; margin: 0; padding-bottom: 0; color: #15171A; font-weight: 400;">{{gift.amount}}</p>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>

<!-- GIFT LINK -->
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; padding-top: 24px;">
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; margin: 0; padding-bottom: 4px; color: #15171A; font-weight: 700;">Redemption link</p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; margin: 0; padding-bottom: 16px; color: #738A94; word-break: break-all;"><a href="{{gift.link}}" style="color: {{accentColor}}; text-decoration: none;">{{gift.link}}</a></p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 13px; margin: 0; padding-bottom: 0; color: #738A94;">This link can be redeemed once and expires on {{gift.expiresAt}}.</p>
</td>
</tr>
</table>
</td>
</tr>

<!-- START FOOTER -->
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 12px; vertical-align: top; padding-top: 56px;">
<p class="small" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; line-height: 18px; font-size: 12px; color: #7C8B9A; font-weight: normal; margin: 0;">This message was sent from <a class="small" href="{{siteUrl}}" style="text-decoration: underline; color: #7C8B9A; font-size: 12px;">{{siteDomain}}</a> to <a class="small" href="mailto:{{toEmail}}" style="text-decoration: underline; color: #7C8B9A; font-size: 12px;">{{toEmail}}</a></p>
</td>
</tr>
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 12px; vertical-align: top;">
<p class="small" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; line-height: 18px; font-size: 12px; color: #7C8B9A; font-weight: normal; margin: 0;">You received this email because you purchased a gift subscription on <a class="small" href="{{siteUrl}}" style="text-decoration: underline; color: #7C8B9A; font-size: 12px;">{{siteTitle}}</a>.</p>
</td>
</tr>

<!-- END FOOTER -->
</table>
</td>
</tr>

<!-- END MAIN CONTENT AREA -->
</table>


<!-- END CENTERED CONTAINER -->
</div>
</td>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">&nbsp;</td>
</tr>
</table>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -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}.`;
}
28 changes: 28 additions & 0 deletions ghost/core/core/server/services/gifts/gift-email-renderer.ts
Original file line number Diff line number Diff line change
@@ -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)
};
}
}
115 changes: 115 additions & 0 deletions ghost/core/core/server/services/gifts/gift-email-service.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
}

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<void> {
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);
}
}
19 changes: 19 additions & 0 deletions ghost/core/core/server/services/gifts/gift-service-wrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading
Loading