diff --git a/services/web/app/src/Features/Email/EmailBuilder.mjs b/services/web/app/src/Features/Email/EmailBuilder.mjs index 33aad13b96..b44515aab0 100644 --- a/services/web/app/src/Features/Email/EmailBuilder.mjs +++ b/services/web/app/src/Features/Email/EmailBuilder.mjs @@ -648,6 +648,43 @@ templates.surrenderAccountForManagedUsers = ctaTemplate({ }, }) +templates.domainReverificationFailed = ctaTemplate({ + subject(opts) { + if (opts.capturedByGroup) { + return `Action needed: re-verify ${opts.domain} to keep adding users automatically` + } + return `${opts.domain} needs re-verifying` + }, + title(opts) { + const domain = _.escape(opts.domain) + if (opts.capturedByGroup) { + return `Action needed: re-verify ${domain}` + } + return `${domain} needs re-verifying` + }, + message(opts) { + const domain = _.escape(opts.domain) + if (opts.capturedByGroup) { + const date = moment(opts.gracePeriodEndDate).format('MMMM D, YYYY') + return [ + `We weren't able to verify your domain ${domain} during our latest check, so its verification has lapsed.`, + `Right now, anyone with an email address at ${domain} is added to your group automatically. If the domain isn't re-verified by ${date}, we'll stop adding them. Existing members aren't affected.`, + `To fix this, check that your DNS TXT record still matches the one shown in your group settings. Once it's in place, we'll re-verify the domain automatically.`, + ] + } + return [ + `We weren't able to verify your domain ${domain} during our latest check, so it's no longer verified.`, + `To re-verify it, check that your DNS TXT record still matches the one shown in your group settings. Once it's in place, we'll verify it automatically.`, + ] + }, + ctaURL(opts) { + return opts.domainSettingsUrl + }, + ctaText() { + return 'Go to group settings' + }, +}) + templates.testEmail = ctaTemplate({ subject() { return `A Test Email from ${settings.appName}` diff --git a/services/web/test/unit/src/Email/EmailBuilder.test.mjs b/services/web/test/unit/src/Email/EmailBuilder.test.mjs index 332a6f3139..90d34c1aca 100644 --- a/services/web/test/unit/src/Email/EmailBuilder.test.mjs +++ b/services/web/test/unit/src/Email/EmailBuilder.test.mjs @@ -1128,6 +1128,95 @@ describe('EmailBuilder', function () { ) }) }) + + describe('domainReverificationFailed', function () { + beforeEach(function (ctx) { + ctx.opts = { + to: 'admin@example.com', + domain: 'example.com', + domainSettingsUrl: + 'https://www.overleaf.com/manage/groups/abc/settings', + } + }) + + describe('when the domain is captured by the group', function () { + beforeEach(function (ctx) { + ctx.opts.capturedByGroup = true + // local-time date so moment's local formatting is timezone-safe + ctx.opts.gracePeriodEndDate = new Date(2026, 5, 30) + ctx.email = ctx.EmailBuilder.buildEmail( + 'domainReverificationFailed', + ctx.opts + ) + }) + + it('builds html and text without undefined', function (ctx) { + expect(ctx.email.html).to.exist + expect(ctx.email.text).to.exist + expect(ctx.email.html.indexOf('undefined')).to.equal(-1) + expect(ctx.email.subject.indexOf('undefined')).to.equal(-1) + }) + + it('leads with action needed and the domain in the subject', function (ctx) { + expect(ctx.email.subject).to.equal( + 'Action needed: re-verify example.com to keep adding users automatically' + ) + }) + + it('includes the grace period deadline', function (ctx) { + expect(ctx.email.html).to.contain('June 30, 2026') + expect(ctx.email.html).to.contain("we'll stop adding them") + }) + + it('links to the domain settings page', function (ctx) { + expect(ctx.email.html).to.contain(ctx.opts.domainSettingsUrl) + expect(ctx.email.text).to.contain(ctx.opts.domainSettingsUrl) + }) + }) + + describe('when the domain is not captured by the group', function () { + beforeEach(function (ctx) { + ctx.opts.capturedByGroup = false + ctx.email = ctx.EmailBuilder.buildEmail( + 'domainReverificationFailed', + ctx.opts + ) + }) + + it('uses the lower-stakes subject', function (ctx) { + expect(ctx.email.subject).to.equal('example.com needs re-verifying') + }) + + it('does not mention a deadline or capture', function (ctx) { + expect(ctx.email.html).to.not.contain("we'll stop adding them") + expect(ctx.email.html.indexOf('undefined')).to.equal(-1) + }) + + it('still links to the domain settings page', function (ctx) { + expect(ctx.email.html).to.contain(ctx.opts.domainSettingsUrl) + }) + }) + + // The cta-email title and body are lodash templates, where <%= %> + // interpolates without escaping (the opposite of EJS), so the domain must + // be escaped in both title() and message(). The domain regex makes such + // input impossible in practice, but this guards the escaping against being + // applied zero or two times. (a&b.example.com appears in both the title and + // the body, so we expect two single-escaped occurrences and none + // double-escaped.) + it('escapes the domain exactly once in the title and body', function (ctx) { + ctx.opts.capturedByGroup = false + ctx.opts.domain = 'a&b.example.com' + ctx.email = ctx.EmailBuilder.buildEmail( + 'domainReverificationFailed', + ctx.opts + ) + expect(ctx.email.html.split('a&b.example.com')).to.have.lengthOf( + 3 + ) + expect(ctx.email.html).to.not.contain('a&amp;b.example.com') + }) + }) }) }) })