Skip to content

Commit bb5be3b

Browse files
authored
Add Mailgun tag for member welcome emails (#26590)
ref https://linear.app/ghost/issue/NY-1027/add-a-tag-to-welcome-emails-in-mailgun-to-make-filtering-easier ### Motivation - Make welcome emails easier to filter in Mailgun by attaching a dedicated tag to those sends. - Allow callers to pass per-message Mailgun tags so specific email types can be identified in logs and analytics. ### Description - Added `MEMBER_WELCOME_EMAIL_TAG = 'member-welcome-email'` and exported it from `member-welcome-emails/constants.js`. - Passed that tag from `MemberWelcomeEmailService` into the mail payload via a new `mailgunTags` message field. - Extended `GhostMailer` to accept `mailgunTags` in `send()` and merge them with default tags via the updated `getTags(additionalTags = [])` implementation, with deduplication. - Added/updated tests to assert that the `mailgunTags` are propagated from the welcome-email flow and merged into Mailgun `o:tag` values. ### Manual testing I tested this out locally with Mailgun, and confirmed that I can see the tags coming through in the logs: <img width="1952" height="99" alt="Screenshot 2026-02-25 at 15 15 58" src="https://github.com/user-attachments/assets/b3cd3ec6-85c0-405c-98b5-32f2f8f03a79" />
1 parent 83a13a8 commit bb5be3b

File tree

8 files changed

+109
-9
lines changed

8 files changed

+109
-9
lines changed

ghost/core/core/server/services/mail/ghost-mailer.js

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Handles sending email for Ghost
33
const _ = require('lodash');
44
const config = require('../../../shared/config');
5+
const logging = require('@tryghost/logging');
56
const errors = require('@tryghost/errors');
67
const tpl = require('@tryghost/tpl');
78
const settingsCache = require('../../../shared/settings-cache');
@@ -18,6 +19,7 @@ const messages = {
1819
};
1920
const EmailAddressParser = require('../email-address/email-address-parser');
2021
const DEFAULT_TAGS = ['ghost-email', 'transactional-email'];
22+
const MAX_MAILGUN_TAGS = 10;
2123

2224
function getDomain() {
2325
const domain = urlUtils.urlFor('home', true).match(new RegExp('^https?://([^/:?#]+)(?:[/:?#]|$)', 'i'));
@@ -62,11 +64,14 @@ function getFromAddress(requestedFromAddress, requestedReplyToAddress) {
6264
function createMessage(message) {
6365
const encoding = 'base64';
6466
const generateTextFromHTML = !message.forceTextContent;
67+
const cleanMessage = {...message};
68+
delete cleanMessage.tags;
69+
delete cleanMessage.forceTextContent;
6570

6671
const addresses = getFromAddress(message.from, message.replyTo);
6772

6873
return {
69-
...message,
74+
...cleanMessage,
7075
...addresses,
7176
generateTextFromHTML,
7277
encoding,
@@ -116,6 +121,7 @@ module.exports = class GhostMailer {
116121
* @param {string} [message.replyTo]
117122
* @param {string} [message.from] - sender email address
118123
* @param {string} [message.text] - text version of this message
124+
* @param {string[]} [message.tags] - optional additional Mailgun tags
119125
* @param {boolean} [message.forceTextContent] - maps to generateTextFromHTML nodemailer option
120126
* which is: "if set to true uses HTML to generate plain text body part from the HTML if the text is not defined"
121127
* (ref: https://github.com/nodemailer/nodemailer/tree/da2f1d278f91b4262e940c0b37638e7027184b1d#e-mail-message-fields)
@@ -131,7 +137,7 @@ module.exports = class GhostMailer {
131137

132138
const messageToSend = createMessage(message);
133139
if (this.state.usingMailgun) {
134-
const tags = this.getTags();
140+
const tags = this.getTags(message.tags);
135141
if (tags.length > 0) {
136142
messageToSend['o:tag'] = tags;
137143
}
@@ -195,14 +201,38 @@ module.exports = class GhostMailer {
195201
return tpl(messages.messageSent);
196202
}
197203

198-
getTags() {
204+
/**
205+
* Builds the Mailgun tag list from defaults, site tag, and optional extra tags.
206+
* @param {string[]} [additionalTags]
207+
* @returns {string[]}
208+
*/
209+
getTags(additionalTags = []) {
199210
const tagList = [...DEFAULT_TAGS];
200211

201212
const siteId = config.get('hostSettings:siteId');
202213
if (siteId) {
203214
tagList.push(`blog-${siteId}`);
204215
}
205216

206-
return tagList;
217+
if (Array.isArray(additionalTags) && additionalTags.length > 0) {
218+
const cleanedTags = additionalTags
219+
.filter(tag => typeof tag === 'string')
220+
.map(tag => tag.trim().toLowerCase())
221+
.filter(tag => tag.length > 0);
222+
223+
tagList.push(...cleanedTags);
224+
}
225+
226+
const uniqueTags = [...new Set(tagList)];
227+
228+
if (uniqueTags.length > MAX_MAILGUN_TAGS) {
229+
const keptTags = uniqueTags.slice(0, MAX_MAILGUN_TAGS);
230+
231+
logging.warn(`[MAIL] Mailgun tag count exceeded ${MAX_MAILGUN_TAGS}; truncating tags from ${uniqueTags.length} to ${MAX_MAILGUN_TAGS}.`);
232+
233+
return keptTags;
234+
}
235+
236+
return uniqueTags;
207237
}
208238
};

ghost/core/core/server/services/member-welcome-emails/constants.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ const MEMBER_WELCOME_EMAIL_SLUGS = {
55
paid: 'member-welcome-email-paid'
66
};
77

8+
const MEMBER_WELCOME_EMAIL_TAG = 'member-welcome-email';
9+
810
const MESSAGES = {
911
NO_MEMBER_WELCOME_EMAIL: 'No member welcome email found',
1012
INVALID_LEXICAL_STRUCTURE: 'Member welcome email has invalid content structure',
@@ -16,6 +18,7 @@ const MESSAGES = {
1618

1719
module.exports = {
1820
MEMBER_WELCOME_EMAIL_LOG_KEY,
21+
MEMBER_WELCOME_EMAIL_TAG,
1922
MEMBER_WELCOME_EMAIL_SLUGS,
2023
MESSAGES
2124
};

ghost/core/core/server/services/member-welcome-emails/service.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const mail = require('../mail');
99
// @ts-expect-error type checker has trouble with the dynamic exporting in models
1010
const {AutomatedEmail, Newsletter} = require('../../models');
1111
const MemberWelcomeEmailRenderer = require('./member-welcome-email-renderer');
12-
const {MEMBER_WELCOME_EMAIL_LOG_KEY, MEMBER_WELCOME_EMAIL_SLUGS, MESSAGES} = require('./constants');
12+
const {MEMBER_WELCOME_EMAIL_LOG_KEY, MEMBER_WELCOME_EMAIL_TAG, MEMBER_WELCOME_EMAIL_SLUGS, MESSAGES} = require('./constants');
1313

1414
class MemberWelcomeEmailService {
1515
#mailer;
@@ -157,6 +157,7 @@ class MemberWelcomeEmailService {
157157
html,
158158
text,
159159
forceTextContent: true,
160+
tags: [MEMBER_WELCOME_EMAIL_TAG],
160161
...senderOptions
161162
});
162163
}

ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2034,7 +2034,6 @@ If you did not make this request, you can simply delete this message. You will n
20342034
exports[`Members API Can add and send a signup confirmation email 5: [metadata 1] 1`] = `
20352035
Object {
20362036
"encoding": "base64",
2037-
"forceTextContent": true,
20382037
"from": "\\"Ghost's Test Site\\" <noreply@127.0.0.1>",
20392038
"generateTextFromHTML": false,
20402039
"headers": Object {

ghost/core/test/e2e-api/admin/__snapshots__/newsletters.test.js.snap

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2179,7 +2179,6 @@ Object {
21792179
exports[`Newsletters API Managed email with custom sending domain Can set newsletter reply-to to any email address with required verification 3: [metadata 1] 1`] = `
21802180
Object {
21812181
"encoding": "base64",
2182-
"forceTextContent": true,
21832182
"from": "\\"Ghost\\" <noreply@sendingdomain.com>",
21842183
"generateTextFromHTML": false,
21852184
"headers": Object {
@@ -3382,7 +3381,6 @@ Object {
33823381
exports[`Newsletters API Managed email without custom sending domain Can set newsletter reply-to to any email address with required verification 3: [metadata 1] 1`] = `
33833382
Object {
33843383
"encoding": "base64",
3385-
"forceTextContent": true,
33863384
"from": "\\"Ghost\\" <default@email.com>",
33873385
"generateTextFromHTML": false,
33883386
"headers": Object {

ghost/core/test/integration/services/member-welcome-emails.test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,7 @@ describe('Member Welcome Emails Integration', function () {
345345
sinon.assert.calledOnce(mailService.GhostMailer.prototype.send);
346346
const sendCall = mailService.GhostMailer.prototype.send.firstCall;
347347
assert.equal(sendCall.args[0].to, memberEmail);
348+
assert.deepEqual(sendCall.args[0].tags, ['member-welcome-email']);
348349
});
349350

350351
it('uses configured sender and reply-to when sending member welcome email', async function () {

ghost/core/test/unit/server/services/mail/ghost-mailer.test.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const mail = require('../../../../../core/server/services/mail');
44
const settingsCache = require('../../../../../core/shared/settings-cache');
55
const configUtils = require('../../../../utils/config-utils');
66
const urlUtils = require('../../../../../core/shared/url-utils');
7+
const logging = require('@tryghost/logging');
78
let mailer;
89
const assert = require('node:assert/strict');
910
const {assertExists} = require('../../../../utils/assertions');
@@ -400,6 +401,74 @@ describe('Mail: Ghostmailer', function () {
400401
assert(!sentMessage['o:tag'].includes('blog-123123'));
401402
});
402403

404+
it('should include custom tags passed by the caller', async function () {
405+
configUtils.set({
406+
hostSettings: {siteId: '123123'}
407+
});
408+
sandbox.stub(settingsCache, 'get').withArgs('email_track_opens').returns(false);
409+
410+
mailer = new mail.GhostMailer();
411+
mailer.state.usingMailgun = true;
412+
const sendMailSpy = sandbox.stub(mailer.transport, 'sendMail').resolves({});
413+
414+
await mailer.send({
415+
to: 'user@example.com',
416+
subject: 'test',
417+
html: 'content',
418+
tags: ['member-welcome-email']
419+
});
420+
421+
const sentMessage = sendMailSpy.firstCall.args[0];
422+
assert(sentMessage['o:tag'].includes('transactional-email'));
423+
assert(sentMessage['o:tag'].includes('member-welcome-email'));
424+
assert.equal(sentMessage.tags, undefined);
425+
assert.equal(sentMessage.forceTextContent, undefined);
426+
});
427+
428+
it('should truncate tags to Mailgun maximum and log warning', async function () {
429+
configUtils.set({
430+
hostSettings: {siteId: '123123'}
431+
});
432+
sandbox.stub(settingsCache, 'get').withArgs('email_track_opens').returns(false);
433+
const warnStub = sandbox.stub(logging, 'warn');
434+
435+
mailer = new mail.GhostMailer();
436+
mailer.state.usingMailgun = true;
437+
const sendMailSpy = sandbox.stub(mailer.transport, 'sendMail').resolves({});
438+
439+
await mailer.send({
440+
to: 'user@example.com',
441+
subject: 'test',
442+
html: 'content',
443+
tags: [
444+
'tag-1',
445+
'tag-2',
446+
'tag-3',
447+
'tag-4',
448+
'tag-5',
449+
'tag-6',
450+
'tag-7',
451+
'tag-8',
452+
'tag-9'
453+
]
454+
});
455+
456+
const sentMessage = sendMailSpy.firstCall.args[0];
457+
assert.deepEqual(sentMessage['o:tag'], [
458+
'ghost-email',
459+
'transactional-email',
460+
'blog-123123',
461+
'tag-1',
462+
'tag-2',
463+
'tag-3',
464+
'tag-4',
465+
'tag-5',
466+
'tag-6',
467+
'tag-7'
468+
]);
469+
sinon.assert.called(warnStub);
470+
});
471+
403472
it('should not add tag when not using Mailgun transport', async function () {
404473
configUtils.set({
405474
hostSettings: {siteId: '999999'}

ghost/core/test/unit/server/services/settings/__snapshots__/settings-bread-service.test.js.snap

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,6 @@ exports[`UNIT > Settings BREAD Service: edit setting members_support_address tri
189189
exports[`UNIT > Settings BREAD Service: edit setting members_support_address triggers email verification 3: [metadata 1] 1`] = `
190190
Object {
191191
"encoding": "base64",
192-
"forceTextContent": true,
193192
"from": "\\"Ghost at 127.0.0.1\\" <noreply@example.com>",
194193
"generateTextFromHTML": false,
195194
"headers": Object {

0 commit comments

Comments
 (0)