1
0
Fork 0

feat(backend): Added newsletter subscriptions

This commit is contained in:
joostdecock 2023-08-05 18:42:52 +02:00
parent 9460d98f6a
commit 2210f26e03
176 changed files with 1728 additions and 629 deletions

View file

@ -61,7 +61,6 @@ backend:
rmdb: 'node ./scripts/rmdb.mjs'
test: 'npx mocha --require mocha-steps tests/index.mjs'
vbuild: *vbuild
prebuild: &sitePrebuild 'node --conditions=internal --experimental-json-modules ./prebuild.mjs'
dev:
build: &nextBuild 'next build'
@ -71,7 +70,7 @@ dev:
develop: *nextDev
i18n: "SITE=dev node --conditions=internal ../shared/prebuild/i18n-only.mjs"
lint: &nextLint 'next lint'
prebuild: *sitePrebuild
prebuild: &sitePrebuild 'node --conditions=internal --experimental-json-modules ./prebuild.mjs'
serve: "pm2 start npm --name 'dev' -- run start"
start: &nextStart 'yarn prebuild && yarn dev'

View file

@ -0,0 +1,5 @@
---
title: Newsletter
---
<ReadMore />

View file

@ -0,0 +1,66 @@
---
title: Why do I have to click again to confirm I want to subscribe after I already clicked the confirmation link you sent me?
---
There are two aspects that cause signing up for our newsletter to require multiple clicks:
- [People should only be able to sign up themselves](#people-should-only-be-able-to-sign-up-themselves)
- [GET requests should not make changes](#get-requests-should-not-make-changes)
## People should only be able to sign up themselves
This one is pretty easy to understand. One should not be able
to subscribe somebody else's email address to the FreeSewing newsletter.
This is why, after indicating you want to sign up, we sent you a confirmation
email to the email address you provided.
If you receive this email, it confirms not only that the email address
is working, but also that you have access to it.
In other words, only after you click the link in the confirmation email can we know for
cerntain that:
- The email address is valid
- The owner of the email address wants to subscribe
That's where it would be over. Except for one technical detail that's also important:
## GET requests should not make changes
<Warning compact>This is more technical and harder to understand</Warning>
Another reason is that while we could make it so that clicking the link in your
email would immeadiatly subcribe you, it would be in violation of internet standards.
Specifically, the __HTTP__ protocol's __GET method__ definition which states that:
<Note>
<h5>GET requests should only retrieve data and should have no other effect.</h5>
[wikipedia.org/wiki/HTTP#HTTP/1.1_request_messages](https://en.wikipedia.org/wiki/HTTP#HTTP/1.1_request_messages)
</Note>
A _GET request_ is what happens when you follow a link. Merely following a link
should not make any changes (like subscribing you to a newsletter).
For example, when you receive an email, your email client
may _preload_ the links in it in the background. So that they are quicker to
load should you click on them.
Obviously, this preloading should not confirm your subscription. Which is why
you need to click a button to confirm. Because that will trigger a __POST request__
and those can make changes.
<Tip>
##### This does not apply to users subscribing through their account
None of this applies to users who subscribe to our newsletter by enabling the
option in our account. In this case, we do not need to go through the email
validation process, since we already did that when you signed up.
For users, subscribing (and unsubscribing) is instant (If you are curious,
we use an idempotent __PUT request__ under the hood).
</Tip>

View file

@ -0,0 +1,66 @@
---
title: Why do I have to click again to confirm I want to subscribe after I already clicked the confirmation link you sent me?
---
There are two aspects that cause signing up for our newsletter to require multiple clicks:
- [People should only be able to sign up themselves](#people-should-only-be-able-to-sign-up-themselves)
- [GET requests should not make changes](#get-requests-should-not-make-changes)
## People should only be able to sign up themselves
This one is pretty easy to understand. One should not be able
to subscribe somebody else's email address to the FreeSewing newsletter.
This is why, after indicating you want to sign up, we sent you a confirmation
email to the email address you provided.
If you receive this email, it confirms not only that the email address
is working, but also that you have access to it.
In other words, only after you click the link in the confirmation email can we know for
cerntain that:
- The email address is valid
- The owner of the email address wants to subscribe
That's where it would be over. Except for one technical detail that's also important:
## GET requests should not make changes
<Warning compact>This is more technical and harder to understand</Warning>
Another reason is that while we could make it so that clicking the link in your
email would immeadiatly subcribe you, it would be in violation of internet standards.
Specifically, the __HTTP__ protocol's __GET method__ definition which states that:
<Note>
<h5>GET requests should only retrieve data and should have no other effect.</h5>
[wikipedia.org/wiki/HTTP#HTTP/1.1_request_messages](https://en.wikipedia.org/wiki/HTTP#HTTP/1.1_request_messages)
</Note>
A _GET request_ is what happens when you follow a link. Merely following a link
should not make any changes (like subscribing you to a newsletter).
For example, when you receive an email, your email client
may _preload_ the links in it in the background. So that they are quicker to
load should you click on them.
Obviously, this preloading should not confirm your subscription. Which is why
you need to click a button to confirm. Because that will trigger a __POST request__
and those can make changes.
<Tip>
##### This does not apply to users subscribing through their account
None of this applies to users who subscribe to our newsletter by enabling the
option in our account. In this case, we do not need to go through the email
validation process, since we already did that when you signed up.
For users, subscribing (and unsubscribing) is instant (If you are curious,
we use an idempotent __PUT request__ under the hood).
</Tip>

View file

@ -24,9 +24,7 @@
"rmdb": "node ./scripts/rmdb.mjs",
"test": "npx mocha --require mocha-steps tests/index.mjs",
"vbuild": "VERBOSE=1 node build.mjs",
"prebuild": "node --conditions=internal --experimental-json-modules ./prebuild.mjs",
"wbuild": "node build.mjs",
"prewbuild": "node --conditions=internal --experimental-json-modules ./prebuild.mjs"
"wbuild": "node build.mjs"
},
"peerDependencies": {},
"dependencies": {

View file

@ -1,14 +0,0 @@
import { prebuildRunner } from '../shared/prebuild/runner.mjs'
/*
* This handles the prebuild step for the FreeSewing backend
* It runs via an NPM run script, so in a pure NodeJS context
*
* See `sites/org/prebuild.mjs` for an example with inline comments
*/
prebuildRunner({
site: 'backend',
prebuild: {
i18n: true,
},
})

View file

@ -30,10 +30,11 @@ model Confirmation {
model Subscriber {
id String @id @default(uuid())
active Boolean @default(false)
createdAt DateTime @default(now())
data String
ehash String @unique
email String
language String @default("en")
updatedAt DateTime @updatedAt
}

View file

@ -35,6 +35,8 @@ const baseConfig = {
env: process.env.NODE_ENV || 'development',
// Maintainer contact
maintainer: 'joost@freesewing.org',
// Backend API base URL
api: process.env.BACKEND_API_URL || 'https://backend3.freesewing.org',
// Feature flags
use: {
github: envToBool(process.env.BACKEND_ENABLE_GITHUB),
@ -44,12 +46,12 @@ const baseConfig = {
},
sanity: envToBool(process.env.BACKEND_ENABLE_SANITY),
ses: envToBool(process.env.BACKEND_ENABLE_AWS_SES),
stripe: envToBool(process.env.BACKEND_ENABLE_PAYMENTS),
tests: {
base: envToBool(process.env.BACKEND_ENABLE_TESTS),
email: envToBool(process.env.BACKEND_ENABLE_TESTS_EMAIL),
sanity: envToBool(process.env.BACKEND_ENABLE_TESTS_SANITY),
},
import: envToBool(process.env.BACKEND_ENABLE_IMPORT),
},
// Config
api,
@ -86,7 +88,7 @@ const baseConfig = {
audience: process.env.BACKEND_JWT_ISSUER || 'freesewing.org',
expiresIn: process.env.BACKEND_JWT_EXPIRY || '7d',
},
languages: ['en', 'de', 'es', 'fr', 'nl'],
languages: ['en', 'de', 'es', 'fr', 'nl', 'uk'],
translations: ['de', 'es', 'fr', 'nl', 'uk'],
measies: measurements,
mfa: {
@ -145,7 +147,7 @@ if (baseConfig.use.github)
if (baseConfig.use.sanity)
baseConfig.sanity = {
project: process.env.SANITY_PROJECT,
dataset: process.env.SANITY_DATASET || 'production',
dataset: process.env.SANITY_DATASET || 'site-content',
token: process.env.SANITY_TOKEN || 'fixmeSetSanityToken',
version: process.env.SANITY_VERSION || 'v2022-10-31',
api: `https://${process.env.SANITY_PROJECT || 'missing-project-id'}.api.sanity.io/${
@ -153,14 +155,6 @@ if (baseConfig.use.sanity)
}`,
}
// Stripe config
if (baseConfig.use.stripe)
baseConfig.stripe = {
keys: {
createIntent: process.env.BACKEND_STRIPE_CREATE_INTENT_KEY || false,
},
}
// AWS SES config (for sending out emails)
if (baseConfig.use.ses)
baseConfig.aws = {
@ -260,10 +254,6 @@ if (envToBool(process.env.BACKEND_ENABLE_OAUTH_GOOGLE)) {
vars.BACKEND_OAUTH_GOOGLE_CLIENT_ID = 'required'
vars.BACKEND_OAUTH_GOOGLE_CLIENT_SECRET = 'requiredSecret'
}
// Vars for Stripe integration
if (envToBool(process.env.BACKEND_ENABLE_PAYMENTS)) {
vars.BACKEND_STRIPE_CREATE_INTENT_KEY = 'requiredSecret'
}
// Vars for (unit) tests
if (envToBool(process.env.BACKEND_ENABLE_TESTS)) {
@ -339,11 +329,6 @@ export function verifyConfig(silent = false) {
config.jwt.secretOrKey.slice(0, 4) + '**redacted**' + config.jwt.secretOrKey.slice(-4),
},
}
if (config.stripe)
dump.stripe = {
...config.stripe.keys,
//token: config.sanity.token.slice(0, 4) + '**redacted**' + config.sanity.token.slice(-4),
}
if (config.sanity)
dump.sanity = {
...config.sanity,

View file

@ -0,0 +1,18 @@
import { ImportModel } from '../models/import.mjs'
export function ImportsController() {}
/*
* Imports newsletters subscribers in v2 format
*/
ImportsController.prototype.subscribers = async (req, res, tools) => {
/*
* This is a special route that uses hard-coded credentials
*/
console.log(req.body)
const Import = new ImportModel(tools)
//await Flow.sendTranslatorInvite(req)
return Import.sendResponse(res)
}

View file

@ -1,34 +0,0 @@
export function PaymentsController() {}
/*
* Create payment intent
*
* This is the endpoint that handles creation of a Stripe payment intent
* See: https://freesewing.dev/reference/backend/api/apikey
*/
PaymentsController.prototype.createIntent = async (req, res, tools) => {
if (!req.body.amount) return this.setResponse(400, 'amountMissing')
if (!req.body.currency) return this.setResponse(400, 'currencyMissing')
const body = new URLSearchParams()
body.append('amount', req.body.amount)
body.append('currency', req.body.currency)
// Call Stripe API with fetch to create the payment inent
const result = await fetch('https://api.stripe.com/v1/payment_intents', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
authorization: `Bearer ${tools.config.stripe.keys.createIntent}`,
},
body,
})
// Convert response to JSON
const json = await result.json()
// Return status code 201 (created) and clientSecret
return res.status(201).send({
result: 'success',
clientSecret: json.client_secret,
})
}

View file

@ -0,0 +1,68 @@
//import fs from 'fs'
//import path from 'path'
import { SubscriberModel } from '../models/subscriber.mjs'
/*
* Load pages for subscribe and unsubscribe confirmation
* Some people might find this extra step annoying,
* but GET requests should not make changes.
*/
//const index = fs.readFileSync(path.resolve('.', 'src', 'landing', 'index.html'))
//app.get('/', async (req, res) => res.set('Content-Type', 'text/html').status(200).send(index))
export function SubscribersController() {}
/*
* Subscribe to the newsletter (sends confirmation email)
* See: https://freesewing.dev/reference/backend/api
*/
SubscribersController.prototype.subscribe = async (req, res, tools) => {
const Subscriber = new SubscriberModel(tools)
await Subscriber.guardedCreate(req)
return Subscriber.sendResponse(res)
}
/*
* Subscribe confirmation
* See: https://freesewing.dev/reference/backend/api
*/
SubscribersController.prototype.subscribeConfirm = async (req, res, tools) => {
const Subscriber = new SubscriberModel(tools)
await Subscriber.subscribeConfirm(req)
return Subscriber.sendResponse(res)
}
/*
* Unsubscribe confirmation
* See: https://freesewing.dev/reference/backend/api
*/
SubscribersController.prototype.unsubscribeConfirm = async (req, res, tools) => {
const Subscriber = new SubscriberModel(tools)
await Subscriber.unsubscribeConfirm(req)
return Subscriber.sendResponse(res)
}
/*
* Confirm newsletter subscription
* See: https://freesewing.dev/reference/backend/api
*/
SubscribersController.prototype.confirm = async (req, res, tools) => {
const Subscriber = new SubscriberModel(tools)
await Subscriber.confirm(req)
return Subscriber.sendResponse(res)
}
/*
* Unsubscribe from the newsletter
* See: https://freesewing.dev/reference/backend/api
*/
SubscribersController.unsubscribe = async (req, res, tools) => {
const Subscriber = new SubscriberModel(tools)
await Subscriber.unsubscribe(req)
return Subscriber.sendResponse(res)
}

View file

@ -0,0 +1,18 @@
import { wrapper } from './shared.mjs'
export const html = wrapper({
content: `
<h1><span role="img">👋</span></h1>
<h2>This is the FreeSewing backend</h2>
<p>
For info about FreeSewing, try <a href="https://freesewing.org/">freesewing.org</a> instead.
</p>
<p>
For info about this backend, refer to <a href="https://freesewing.dev/reference/backend">the FreeSewing backend refefence documentation</a>.
</p>
<p>
For questions, join us at
<a href="https://discord.freesewing.org/">discord.freesewing.org</a>
</p>
`,
})

View file

@ -0,0 +1,16 @@
export const wrapper = ({ title = 'FreeSewing', header, content, footer }) => `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title}</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="wrapper">
${header ? header : '<span></span>'}
<div class="msg">${content}</div>
<img src="/avatar.svg" />
</div>
</body>
</html>`

View file

@ -1,8 +1,6 @@
// Dependencies
import express from 'express'
import chalk from 'chalk'
import path from 'path'
import fs from 'fs'
import { PrismaClient } from '@prisma/client'
import passport from 'passport'
// Routes
@ -22,6 +20,8 @@ import { mailer } from './utils/email.mjs'
// Swagger
import swaggerUi from 'swagger-ui-express'
import { openapi } from '../openapi/index.mjs'
// Catch-all page
import { html as catchAll } from './html/catch-all.mjs'
// Bootstrap
const config = verifyConfig()
@ -49,9 +49,7 @@ loadPassportMiddleware(passport, tools)
// Load routes
for (const type in routes) routes[type](tools)
// Catch-all route (Load index.html once instead of at every request)
const index = fs.readFileSync(path.resolve('.', 'src', 'landing', 'index.html'))
app.get('/', async (req, res) => res.set('Content-Type', 'text/html').status(200).send(index))
app.get('/', async (req, res) => res.set('Content-Type', 'text/html').status(200).send(catchAll))
// Start listening for requests
app.listen(config.port, (err) => {

View file

@ -1,29 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FreeSewing backend</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="wrapper">
<span></span>
<div class="msg">
<h1><span role="img">👋</span></h1>
<h2>This is the FreeSewing backend</h2>
<p>
For info about FreeSewing, try <a href="https://freesewing.org/">freesewing.org</a> instead.
</p>
<p>
For info about this backend, refer to <a href="https://freesewing.dev/reference/backend">the FreeSewing backend refefence documentation</a>.
</p>
<p>
For questions, join us at
<a href="https://discord.freesewing.org/">discord.freesewing.org</a>
</p>
</div>
<img src="/avatar.svg" />
</div>
</body>
</html>

View file

@ -1,52 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Your request is invalid</title>
<link
href="https://fonts.googleapis.com/css?family=Raleway:300,500&display=swap"
rel="stylesheet"
/>
<style>
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
color: #f8f9fa;
line-height: 1.25;
font-size: 24px;
background: #212529;
}
h1 {
font-family: 'Raleway', sans-serif;
font-size: 3rem;
font-weight: 700;
margin: 0 0 2rem;
}
img {
width: 166px;
position: absolute;
bottom: 20px;
left: calc(50% - 83px);
}
div.msg {
text-align: left;
max-width: 36ch;
margin: 6rem auto;
}
a,
a:visited,
a:active {
color: #d0bfff !important;
text-decoration: none;
}
</style>
</head>
<body>
<div class="msg">
<h1>What are you doing?</h1>
<p>Try <a href="https://freesewing.org/">freesewing.org</a> instead.</p>
</div>
<img src="https://freesewing.org/avatar.svg" />
</body>
</html>

View file

@ -0,0 +1,297 @@
import { hash, randomString } from '../utils/crypto.mjs'
import { setUserAvatar } from '../utils/sanity.mjs'
import { log } from '../utils/log.mjs'
import { clean, i18nUrl } from '../utils/index.mjs'
export function SubscriberModel(tools) {
this.config = tools.config
this.prisma = tools.prisma
this.mailer = tools.email
this.decrypt = tools.decrypt
this.encrypt = tools.encrypt
this.encryptedFields = ['email']
this.clear = {} // For holding decrypted data
return this
}
SubscriberModel.prototype.guardedCreate = async function ({ body }) {
if (!body.email || typeof body.email !== 'string') return this.setResponse(400, 'emailMissing')
if (!body.language || !this.config.languages.includes(body.language.toLowerCase()))
return this.setResponse(400, 'languageMissing')
// Clean up email address and hash it
const email = clean(body.email)
const language = body.language.toLowerCase()
const ehash = hash(email)
log.info(`New newsletter subscriber: ${email}`)
// Check whether this is a unit test
const isTest = this.isTest(body)
// Check to see if this email address is already subscribed.
let newSubscriber = false
await this.read({ ehash })
if (!this.record) {
// No record found. Create subscriber record.
newSubscriber = true
const data = await this.cloak({ ehash, email, language, active: false })
try {
this.record = await this.prisma.subscriber.create({ data })
} catch (err) {
log.warn(err, 'Could not create subscriber record')
return this.setResponse(500, 'createSubscriberFailed')
}
}
// Construct the various URLs
const actionUrl = i18nUrl(
`/newsletter/${this.record.active ? 'un' : ''}subscribe/${this.record.id}/${ehash}`
)
// Send out confirmation email unless it's a test and we don't want to send test emails
if (!isTest || this.config.use.tests.email) {
const template = newSubscriber ? 'nlsub' : this.record.active ? 'nlsubact' : 'nlsubinact'
await this.mailer.send({
template,
language,
to: email,
replacements: {
actionUrl,
whyUrl: i18nUrl(body.language, `/docs/faq/email/why-${template}`),
supportUrl: i18nUrl(body.language, `/patrons/join`),
},
})
}
const returnData = { language, email }
if (isTest) {
returnData.id = this.record.id
returnData.ehash = ehash
}
return this.setResponse(200, 'success', { data: returnData })
}
SubscriberModel.prototype.subscribeConfirm = async function ({ body }) {
const { id, ehash } = body
if (!id) return this.setResponse(400, 'idMissing')
if (!ehash) return this.setResponse(400, 'ehashMissing')
// Find subscription
await this.read({ ehash })
if (!this.record) {
// Subscriber not found
return this.setResponse(404, 'subscriberNotFound')
}
if (this.record.status !== true) {
// Update username
try {
await this.unguardedUpdate({ active: true })
} catch (err) {
log.warn(err, 'Could not update active state after subscribe confirmation')
return this.setResponse(500, 'subscriberActivationFailed')
}
}
return this.setResponse(200, 'success')
}
SubscriberModel.prototype.unsubscribeConfirm = async function ({ body }) {
const { id, ehash } = body
if (!id) return this.setResponse(400, 'idMissing')
if (!ehash) return this.setResponse(400, 'ehashMissing')
// Find subscription
await this.read({ ehash })
if (this.record) {
// Remove record
try {
await this.unguardedDelete()
} catch (err) {
log.warn(err, 'Could not remove subscriber')
return this.setResponse(500, 'subscriberRemovalFailed')
}
}
return this.setResponse(200, 'success')
}
/*
* Updates the subscriber data
* Used when we create the data ourselves so we know it's safe
*/
SubscriberModel.prototype.unguardedUpdate = async function (data) {
try {
this.record = await this.prisma.subscriber.update({
where: { id: this.record.id },
data,
})
} catch (err) {
log.warn(err, 'Could not update subscriber record')
process.exit()
return this.setResponse(500, 'updateSubscriberFailed')
}
await this.reveal()
return this.setResponse(200)
}
/*
* Removes the subscriber record
* Used when we call for removal ourselves so we know it's safe
*/
SubscriberModel.prototype.unguardedDelete = async function () {
await this.prisma.subscriber.delete({ where: { id: this.record.id } })
this.record = null
this.clear = null
return this.subscriberExists()
}
/*
* Loads a subscriber from the database based on the where clause you pass it
*
* Stores result in this.record
*/
SubscriberModel.prototype.read = async function (where) {
try {
this.record = await this.prisma.subscriber.findUnique({ where })
} catch (err) {
log.warn({ err, where }, 'Could not read subscriber')
}
this.reveal()
return this.subscriberExists()
}
/*
* Checks this.record and sets a boolean to indicate whether
* the subscription exists or not
*
* Stores result in this.exists
*/
SubscriberModel.prototype.subscriberExists = function () {
this.exists = this.record ? true : false
return this
}
/*
* Loads a measurements set from the database based on the where clause you pass it
* In addition prepares it for returning the set data
*
* Stores result in this.record
*/
SubscriberModel.prototype.guardedRead = async function ({ params, user }) {
if (!this.rbac.readSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
await this.read({ id: parseInt(params.id) })
if (!this.record) return this.setResponse(404)
if (this.record.userId !== user.uid && !this.rbac.bughunter(user)) {
return this.setResponse(403, 'insufficientAccessLevel')
}
return this.setResponse(200, false, {
result: 'success',
set: this.asSet(),
})
}
/*
* Helper method to decrypt at-rest data
*/
SubscriberModel.prototype.reveal = async function () {
this.clear = {}
if (this.record) {
for (const field of this.encryptedFields) {
try {
this.clear[field] = this.decrypt(this.record[field])
} catch (err) {
console.log(err)
}
}
}
return this
}
/*
* Helper method to encrypt at-rest data
*/
SubscriberModel.prototype.cloak = function (data) {
for (const field of this.encryptedFields) {
if (typeof data[field] !== 'undefined') {
data[field] = this.encrypt(data[field])
}
}
return data
}
/*
* Removes the subscriber - No questions asked
*/
SubscriberModel.prototype.unguardedDelete = async function () {
await this.prisma.subscriber.delete({ where: { id: this.record.id } })
this.record = null
this.clear = null
return this.subscriberExists()
}
/*
* Helper method to set the response code, result, and body
*
* Will be used by this.sendResponse()
*/
SubscriberModel.prototype.setResponse = function (
status = 200,
error = false,
data = {},
rawData = false
) {
this.response = {
status,
body: rawData
? data
: {
result: 'success',
...data,
},
}
if (status > 201) {
this.response.body.error = error
this.response.body.result = 'error'
this.error = true
} else this.error = false
return this.subscriberExists()
}
/*
* Helper method to send response (as JSON)
*/
SubscriberModel.prototype.sendResponse = async function (res) {
return res.status(this.response.status).send(this.response.body)
}
/*
* Update method to determine whether this request is part of a (unit) test
*/
SubscriberModel.prototype.isTest = function (body) {
// Disalowing tests in prodution is hard-coded to protect people from themselves
if (this.config.env === 'production' && !this.config.tests.production) return false
if (!body.test) return false
if (body.email && !body.email.split('@').pop() === this.config.tests.domain) return false
return true
}

View file

@ -0,0 +1,14 @@
import { ImportController } from '../controllers/import.mjs'
const Import = new ImportController()
export function importsRoutes(tools) {
const { app } = tools
/*
* All these routes use hard-coded credentials because they should never be used
* outside the v2-v3 migration which is handled by joost
*/
// Import newsletter subscriptions
app.post('/import/subscribers', (req, res) => Import.subscribers(req, res, tools))
}

View file

@ -5,8 +5,8 @@ import { patternsRoutes } from './patterns.mjs'
import { confirmationsRoutes } from './confirmations.mjs'
import { curatedSetsRoutes } from './curated-sets.mjs'
import { issuesRoutes } from './issues.mjs'
import { subscribersRoutes } from './subscribers.mjs'
import { flowsRoutes } from './flows.mjs'
import { paymentsRoutes } from './payments.mjs'
export const routes = {
apikeysRoutes,
@ -16,6 +16,6 @@ export const routes = {
confirmationsRoutes,
curatedSetsRoutes,
issuesRoutes,
subscribersRoutes,
flowsRoutes,
paymentsRoutes,
}

View file

@ -1,15 +0,0 @@
import { PaymentsController } from '../controllers/payments.mjs'
const Payments = new PaymentsController()
export function paymentsRoutes(tools) {
const { app } = tools
/*
* Only add these routes if payments are enabled in the config
*/
if (tools.config.use.stripe) {
// Create payment intent
app.post('/payments/intent', (req, res) => Payments.createIntent(req, res, tools))
}
}

View file

@ -0,0 +1,20 @@
import { SubscribersController } from '../controllers/subscribers.mjs'
const Subscriber = new SubscribersController()
export function subscribersRoutes(tools) {
const { app } = tools
/*
* None of these require authentication
*/
// Subscribe to the newsletter
app.post('/subscriber', (req, res) => Subscriber.subscribe(req, res, tools))
// Confirm subscription to the newsletter
app.put('/subscriber', (req, res) => Subscriber.subscribeConfirm(req, res, tools))
// Unsubscribe from newsletter
app.delete('/subscriber', (req, res) => Subscriber.unsubscribeConfirm(req, res, tools))
}

View file

@ -0,0 +1,10 @@
{
"greeting": "Ganz liebe Grüße",
"ps-pre-link": "FreeSewing ist kostenlos (duh), aber bitte",
"ps-link": "werde ein/e Förderer/-in",
"ps-post-link": "wenn du es dir leisten kannst.",
"text-ps": "FreeSewing ist kostenlos, aber wenn du es dir leisten kannst, werde bitte Förderer/-in.",
"notMarketing": "Dies ist keine Marketing-E-Mail, sondern eine Transaktions-E-Mail über dein FreeSewing-Konto.",
"seeWhy": "Weitere Informationen findest du hier:",
"whyDidIGetThis": "Warum habe ich diese E-Mail bekommen?"
}

View file

@ -1,9 +0,0 @@
#Shared
greeting: Ganz liebe Grüße
ps-pre-link: FreeSewing ist kostenlos (duh), aber bitte
ps-link: werde ein/e Förderer/-in
ps-post-link: wenn du es dir leisten kannst.
text-ps: 'FreeSewing ist kostenlos, aber wenn du es dir leisten kannst, werde bitte Förderer/-in.'
notMarketing: Dies ist keine Marketing-E-Mail, sondern eine Transaktions-E-Mail über dein FreeSewing-Konto.
seeWhy: 'Weitere Informationen findest du hier:'
whyDidIGetThis: Warum habe ich diese E-Mail bekommen?

View file

@ -0,0 +1,8 @@
{
"subject": "[FreeSewing] Bestätige deine neue E-Mail-Adresse",
"heading": "Funktioniert diese neue E-Mail-Adresse?",
"lead": "Um deine neue E-Mail-Adresse zu bestätigen, klicke auf das große schwarze Rechteck unten:",
"text-lead": "Um deine neue E-Mail-Adresse zu bestätigen, klicke auf den Link unten:",
"button": "Bestätige die E-Mail-Änderung",
"closing": "Mehr muss nicht getan werden."
}

View file

@ -1,6 +0,0 @@
subject: "[FreeSewing] Bestätige deine neue E-Mail-Adresse"
heading: Funktioniert diese neue E-Mail-Adresse?
lead: 'Um deine neue E-Mail-Adresse zu bestätigen, klicke auf das große schwarze Rechteck unten:'
text-lead: 'Um deine neue E-Mail-Adresse zu bestätigen, klicke auf den Link unten:'
button: Bestätige die E-Mail-Änderung
closing: Mehr muss nicht getan werden.

View file

@ -0,0 +1,8 @@
{
"subject": "[FreeSewing] Confirm your new E-mail address",
"heading": "Does this new E-mail address work?",
"lead": "To confirm your new E-mail address, click the big black rectangle below:",
"text-lead": "To confirm your new E-mail address, click the link below:",
"button": "Confirm E-mail change",
"closing": "That's all it takes."
}

View file

@ -1,6 +0,0 @@
subject: "[FreeSewing] Confirm your new E-mail address"
heading: Does this new E-mail address work?
lead: 'To confirm your new E-mail address, click the big black rectangle below:'
text-lead: 'To confirm your new E-mail address, click the link below:'
button: Confirm E-mail change
closing: That's all it takes.

View file

@ -0,0 +1,8 @@
{
"subject": "[FreeSewing] Confirma tu nueva dirección de correo electrónico",
"heading": "¿Funciona esta nueva dirección de correo electrónico?",
"lead": "Para confirmar tu nueva dirección de correo electrónico, haz clic en el rectángulo negro grande de abajo:",
"text-lead": "Para confirmar tu nueva dirección de correo electrónico, haz clic en el siguiente enlace:",
"button": "Confirmar cambio de e-mail",
"closing": "Eso es todo lo que hace falta."
}

View file

@ -1,6 +0,0 @@
subject: "[FreeSewing] Confirma tu nueva dirección de correo electrónico"
heading: '¿Funciona esta nueva dirección de correo electrónico?'
lead: 'Para confirmar tu nueva dirección de correo electrónico, haz clic en el rectángulo negro grande de abajo:'
text-lead: 'Para confirmar tu nueva dirección de correo electrónico, haz clic en el siguiente enlace:'
button: Confirmar cambio de e-mail
closing: Eso es todo lo que hace falta.

View file

@ -0,0 +1,8 @@
{
"subject": "[FreeSewing] Confirme ta nouvelle adresse e-mail",
"heading": "Cette nouvelle adresse électronique fonctionne-t-elle ?",
"lead": "Pour confirmer ta nouvelle adresse e-mail, clique sur le grand rectangle noir ci-dessous :",
"text-lead": "Pour confirmer ta nouvelle adresse e-mail, clique sur le lien ci-dessous :",
"button": "Confirmer le changement d'e-mail",
"closing": "C'est tout ce qu'il faut."
}

View file

@ -1,6 +0,0 @@
subject: "[FreeSewing] Confirme ta nouvelle adresse e-mail"
heading: Cette nouvelle adresse électronique fonctionne-t-elle ?
lead: 'Pour confirmer ta nouvelle adresse e-mail, clique sur le grand rectangle noir ci-dessous :'
text-lead: 'Pour confirmer ta nouvelle adresse e-mail, clique sur le lien ci-dessous :'
button: Confirmer le changement d'e-mail
closing: C'est tout ce qu'il faut.

View file

@ -1,10 +1,11 @@
import { buttonRow, closingRow, headingRow, lead1Row, wrap } from '../shared/blocks.mjs'
// Translations
import en from '../../../../public/locales/en/emailchange.json' assert { type: 'json' }
import de from '../../../../public/locales/de/emailchange.json' assert { type: 'json' }
import es from '../../../../public/locales/es/emailchange.json' assert { type: 'json' }
import fr from '../../../../public/locales/fr/emailchange.json' assert { type: 'json' }
import nl from '../../../../public/locales/nl/emailchange.json' assert { type: 'json' }
import en from './en.json' assert { type: 'json' }
import de from './de.json' assert { type: 'json' }
import es from './es.json' assert { type: 'json' }
import fr from './fr.json' assert { type: 'json' }
import nl from './nl.json' assert { type: 'json' }
import uk from './uk.json' assert { type: 'json' }
export const emailchange = {
html: wrap.html(`
@ -16,4 +17,4 @@ export const emailchange = {
text: wrap.text(`${headingRow.text}${lead1Row.text}${buttonRow.text}${closingRow.text}`),
}
export const translations = { en, de, es, fr, nl }
export const translations = { en, de, es, fr, nl, uk }

View file

@ -0,0 +1,8 @@
{
"subject": "[FreeSewing] Bevestig je nieuwe e-mailadres",
"heading": "Werkt dit nieuwe e-mailadres?",
"lead": "Klik op de grote zwarte rechthoek hieronder om je nieuwe e-mailadres te bevestigen:",
"text-lead": "Klik op de link hieronder om je nieuwe e-mailadres te bevestigen:",
"button": "E-mailwijziging bevestigen",
"closing": "Langer hoeft dat niet te duren."
}

View file

@ -1,6 +0,0 @@
subject: "[FreeSewing] Bevestig je nieuwe e-mailadres"
heading: Werkt dit nieuwe e-mailadres?
lead: 'Klik op de grote zwarte rechthoek hieronder om je nieuwe e-mailadres te bevestigen:'
text-lead: 'Klik op de link hieronder om je nieuwe e-mailadres te bevestigen:'
button: E-mailwijziging bevestigen
closing: Langer hoeft dat niet te duren.

View file

@ -0,0 +1,8 @@
{
"subject": "[FreeSewing] Підтвердіть свою нову електронну адресу",
"heading": "Чи працює ця нова електронна адреса?",
"lead": "Щоб підтвердити свою нову електронну адресу, натисніть на великий чорний прямокутник нижче:",
"text-lead": "Щоб підтвердити свою нову електронну адресу, перейдіть за посиланням нижче:",
"button": "Підтвердити зміну електронної пошти",
"closing": "Це все, що потрібно."
}

View file

@ -1,6 +0,0 @@
subject: "[FreeSewing] Підтвердіть свою нову електронну адресу"
heading: Чи працює ця нова електронна адреса?
lead: 'Щоб підтвердити свою нову електронну адресу, натисніть на великий чорний прямокутник нижче:'
text-lead: 'Щоб підтвердити свою нову електронну адресу, перейдіть за посиланням нижче:'
button: Підтвердити зміну електронної пошти
closing: Це все, що потрібно.

View file

@ -0,0 +1,10 @@
{
"greeting": "love",
"ps-pre-link": "FreeSewing is free (duh), but please",
"ps-link": "become a patron",
"ps-post-link": "if you can afford it.",
"text-ps": "FreeSewing is free (duh), but please become a patron if you can afford it.",
"notMarketing": "This is not marketing, but a transactional email about your FreeSewing account.",
"seeWhy": "For more info, see:",
"whyDidIGetThis": "Why did I get this email?"
}

View file

@ -1,9 +0,0 @@
# Shared
greeting: love
ps-pre-link: FreeSewing is free (duh), but please
ps-link: become a patron
ps-post-link: if you can afford it.
text-ps: 'FreeSewing is free (duh), but please become a patron if you can afford it.'
notMarketing: This is not marketing, but a transactional email about your FreeSewing account.
seeWhy: 'For more info, see:'
whyDidIGetThis: Why did I get this email?

View file

@ -0,0 +1,10 @@
{
"greeting": "con amor",
"ps-pre-link": "FreeSewing es gratis (duh), pero por favor",
"ps-link": "conviértete en un mecenas",
"ps-post-link": "si te lo puedes permitir.",
"text-ps": "FreeSewing es gratis (duh), pero hazte mecenas si puedes permitírtelo.",
"notMarketing": "No se trata de marketing, sino de un correo electrónico transaccional sobre tu cuenta de FreeSewing.",
"seeWhy": "Para más información, consulta:",
"whyDidIGetThis": "¿Por qué he recibido este correo electrónico?"
}

View file

@ -1,9 +0,0 @@
#Shared
greeting: con amor
ps-pre-link: FreeSewing es gratis (duh), pero por favor
ps-link: conviértete en un mecenas
ps-post-link: si te lo puedes permitir.
text-ps: 'FreeSewing es gratis (duh), pero hazte mecenas si puedes permitírtelo.'
notMarketing: No se trata de marketing, sino de un correo electrónico transaccional sobre tu cuenta de FreeSewing.
seeWhy: 'Para más información, consulta:'
whyDidIGetThis: '¿Por qué he recibido este correo electrónico?'

View file

@ -0,0 +1,10 @@
{
"greeting": "bise",
"ps-pre-link": "FreeSewing est gratuit (duh), mais s'il te plaît...",
"ps-link": "devenir mécène",
"ps-post-link": "si tu peux te le permettre.",
"text-ps": "FreeSewing est gratuit (duh), mais deviens un mécène si tu peux te le permettre.",
"notMarketing": "Il ne s'agit pas de marketing, mais d'un email transactionnel concernant ton compte FreeSewing.",
"seeWhy": "Pour plus d'infos, voir :",
"whyDidIGetThis": "Pourquoi ai-je reçu cet e-mail ?"
}

View file

@ -1,9 +0,0 @@
#Shared
greeting: bise
ps-pre-link: FreeSewing est gratuit (duh), mais s'il te plaît...
ps-link: devenir mécène
ps-post-link: si tu peux te le permettre.
text-ps: 'FreeSewing est gratuit (duh), mais deviens un mécène si tu peux te le permettre.'
notMarketing: Il ne s'agit pas de marketing, mais d'un email transactionnel concernant ton compte FreeSewing.
seeWhy: 'Pour plus d''infos, voir :'
whyDidIGetThis: Pourquoi ai-je reçu cet e-mail ?

View file

@ -0,0 +1,7 @@
{
"subject": "[FreeSewing] Abschied nehmen",
"heading": "FIXME",
"lead": "fixme",
"text-lead": "fixme",
"closing": "fixme"
}

View file

@ -1,5 +0,0 @@
subject: '[FreeSewing] Abschied nehmen'
heading: FIXME
lead: fixme
text-lead: fixme
closing: fixme

View file

@ -0,0 +1,7 @@
{
"subject": "[FreeSewing] Farewell",
"heading": "FIXME",
"lead": "fixme",
"text-lead": "fixme",
"closing": "fixme"
}

View file

@ -1,5 +0,0 @@
subject: '[FreeSewing] Farewell'
heading: FIXME
lead: fixme
text-lead: fixme
closing: fixme

View file

@ -0,0 +1,7 @@
{
"subject": "[FreeSewing] Adiós",
"heading": "FIXME",
"lead": "fixme",
"text-lead": "fixme",
"closing": "fixme"
}

View file

@ -1,5 +0,0 @@
subject: '[FreeSewing] Adiós'
heading: FIXME
lead: fixme
text-lead: fixme
closing: fixme

View file

@ -0,0 +1,7 @@
{
"subject": "[FreeSewing] Adieu",
"heading": "FIXME",
"lead": "fixme",
"text-lead": "fixme",
"closing": "fixme"
}

View file

@ -1,5 +0,0 @@
subject: '[FreeSewing] Adieu'
heading: FIXME
lead: fixme
text-lead: fixme
closing: fixme

View file

@ -1,10 +1,11 @@
import { headingRow, wrap } from '../shared/blocks.mjs'
// Translations
import en from '../../../../public/locales/en/goodbye.json' assert { type: 'json' }
import de from '../../../../public/locales/de/goodbye.json' assert { type: 'json' }
import es from '../../../../public/locales/es/goodbye.json' assert { type: 'json' }
import fr from '../../../../public/locales/fr/goodbye.json' assert { type: 'json' }
import nl from '../../../../public/locales/nl/goodbye.json' assert { type: 'json' }
import en from './en.json' assert { type: 'json' }
import de from './de.json' assert { type: 'json' }
import es from './es.json' assert { type: 'json' }
import fr from './fr.json' assert { type: 'json' }
import nl from './nl.json' assert { type: 'json' }
import uk from './uk.json' assert { type: 'json' }
export const goodbye = {
html: wrap.html(`
@ -35,4 +36,4 @@ joost
PS: {{ps}}`),
}
export const translations = { en, de, es, fr, nl }
export const translations = { en, de, es, fr, nl, uk }

View file

@ -0,0 +1,7 @@
{
"subject": "[FreeSewing] Vaarwel",
"heading": "FIXME",
"lead": "fixme",
"text-lead": "fixme",
"closing": "fixme"
}

View file

@ -1,5 +0,0 @@
subject: '[FreeSewing] Vaarwel'
heading: FIXME
lead: fixme
text-lead: fixme
closing: fixme

View file

@ -0,0 +1,7 @@
{
"subject": "[FreeSewing] Прощавай.",
"heading": "FIXME",
"lead": "fixme",
"text-lead": "fixme",
"closing": "fixme"
}

View file

@ -1,5 +0,0 @@
subject: '[FreeSewing] Прощавай.'
heading: FIXME
lead: fixme
text-lead: fixme
closing: fixme

View file

@ -1,19 +1,22 @@
import { emailchange, translations as emailchangeTranslations } from './emailchange/index.mjs'
import { goodbye, translations as goodbyeTranslations } from './goodbye/index.mjs'
import { signinlink, translations as signinlinkTranslations } from './signinlink/index.mjs'
import { newslettersub, translations as newslettersubTranslations } from './newslettersub/index.mjs'
import { passwordreset, translations as passwordresetTranslations } from './passwordreset/index.mjs'
import { signup, translations as signupTranslations } from './signup/index.mjs'
import { signupaea, translations as signupaeaTranslations } from './signup-aea/index.mjs'
import { signupaed, translations as signupaedTranslations } from './signup-aed/index.mjs'
import { transinvite, translations as transinviteTranslations } from './transinvite/index.mjs'
import { langsuggest } from './langsuggest/index.mjs'
import { nlsub, translations as nlsubTranslations } from './nlsub/index.mjs'
import { nlsubact, translations as nlsubactTranslations } from './nlsubact/index.mjs'
import { nlsubinact, translations as nlsubinactTranslations } from './nlsubinact/index.mjs'
// Shared translations
import en from '../../../public/locales/en/email.json' assert { type: 'json' }
import de from '../../../public/locales/de/email.json' assert { type: 'json' }
import es from '../../../public/locales/es/email.json' assert { type: 'json' }
import fr from '../../../public/locales/fr/email.json' assert { type: 'json' }
import nl from '../../../public/locales/nl/email.json' assert { type: 'json' }
import en from './en.json' assert { type: 'json' }
import de from './de.json' assert { type: 'json' }
import es from './es.json' assert { type: 'json' }
import fr from './fr.json' assert { type: 'json' }
import nl from './nl.json' assert { type: 'json' }
import uk from './uk.json' assert { type: 'json' }
/*
* Everything is kept lowercase here because these key names are used in URLS
@ -22,13 +25,15 @@ export const templates = {
emailchange,
goodbye,
signinlink,
newslettersub,
passwordreset,
signup,
'signup-aea': signupaea,
'signup-aed': signupaed,
transinvite,
langsuggest,
nlsub,
nlsubact,
nlsubinact,
}
/*
@ -47,12 +52,14 @@ export const translations = {
emailchange: emailchangeTranslations,
goodbye: goodbyeTranslations,
signinlink: signinlinkTranslations,
newslettersub: newslettersubTranslations,
passwordreset: passwordresetTranslations,
signup: signupTranslations,
'signup-aea': signupaeaTranslations,
'signup-aed': signupaedTranslations,
transinvite: transinviteTranslations,
langsuggest: noTranslations,
shared: { en, de, es, fr, nl },
nlsub: nlsubTranslations,
nlsubact: nlsubactTranslations,
nlsubinact: nlsubinactTranslations,
shared: { en, de, es, fr, nl, uk },
}

View file

@ -1,5 +0,0 @@
subject: '[FreeSewing] newsletter sub fixme'
heading: FIXME
lead: fixme
text-lead: fixme
closing: fixme

View file

@ -1,5 +0,0 @@
subject: '[FreeSewing] newsletter sub fixme'
heading: FIXME
lead: fixme
text-lead: fixme
closing: fixme

View file

@ -1,5 +0,0 @@
subject: '[FreeSewing] boletín sub fixme'
heading: FIXME
lead: fixme
text-lead: fixme
closing: fixme

View file

@ -1,5 +0,0 @@
subject: '[FreeSewing] newsletter sub fixme'
heading: FIXME
lead: fixme
text-lead: fixme
closing: fixme

View file

@ -1,24 +0,0 @@
import { buttonRow, closingRow, headingRow, lead1Row, wrap } from '../shared/blocks.mjs'
// Translations
import en from '../../../../public/locales/en/newslettersub.json' assert { type: 'json' }
import de from '../../../../public/locales/de/newslettersub.json' assert { type: 'json' }
import es from '../../../../public/locales/es/newslettersub.json' assert { type: 'json' }
import fr from '../../../../public/locales/fr/newslettersub.json' assert { type: 'json' }
import nl from '../../../../public/locales/nl/newslettersub.json' assert { type: 'json' }
export const newslettersub = {
html: wrap.html(`
${headingRow.html}
${lead1Row.html}
${buttonRow.html}
${closingRow.html}
`),
text: wrap.text(`
${headingRow.text}
${lead1Row.text}
${buttonRow.text}
${closingRow.text}
`),
}
export const translations = { en, de, es, fr, nl }

View file

@ -1,5 +0,0 @@
subject: '[FreeSewing] nieuwsbrief sub fixme'
heading: FIXME
lead: fixme
text-lead: fixme
closing: fixme

View file

@ -1,5 +0,0 @@
subject: '[FreeSewing] інформаційний бюлетень sub fixme'
heading: FIXME
lead: fixme
text-lead: fixme
closing: fixme

View file

@ -0,0 +1,10 @@
{
"greeting": "liefs",
"ps-pre-link": "FreeSewing is gratis (duh), maar alsjeblieft",
"ps-link": "word mecenas",
"ps-post-link": "als je het je kunt veroorloven.",
"text-ps": "FreeSewing is gratis (duh), maar word alsjeblieft beschermer als je het je kunt veroorloven.",
"notMarketing": "Dit is geen marketing, maar een transactie-e-mail over je FreeSewing account.",
"seeWhy": "Zie voor meer informatie:",
"whyDidIGetThis": "Waarom kreeg ik deze e-mail?"
}

View file

@ -1,9 +0,0 @@
#Shared
greeting: liefs
ps-pre-link: FreeSewing is gratis (duh), maar alsjeblieft
ps-link: word mecenas
ps-post-link: als je het je kunt veroorloven.
text-ps: 'FreeSewing is gratis (duh), maar word alsjeblieft beschermer als je het je kunt veroorloven.'
notMarketing: Dit is geen marketing, maar een transactie-e-mail over je FreeSewing account.
seeWhy: 'Zie voor meer informatie:'
whyDidIGetThis: Waarom kreeg ik deze e-mail?

View file

@ -0,0 +1,9 @@
{
"subject": "[FreeSewing] Confirm your newsletter subscription",
"heading": "Please confirm your newsletter subscription",
"lead": "To confirm your subscription to the FreeSewing newsletter, click the big rectangle below:",
"text-lead": "To confirm your subscription to the FreeSewing newsletter, click the link below:",
"button": "Confirm subscription",
"closing": "You (or someone else) tried to subscribe this email address to the FreeSewing newsletter just now.",
"greeting": "love,"
}

View file

@ -0,0 +1,9 @@
{
"subject": "[FreeSewing] Confirm your newsletter subscription",
"heading": "Please confirm your newsletter subscription",
"lead": "To confirm your subscription to the FreeSewing newsletter, click the big rectangle below:",
"text-lead": "To confirm your subscription to the FreeSewing newsletter, click the link below:",
"button": "Confirm subscription",
"closing": "You (or someone else) tried to subscribe this email address to the FreeSewing newsletter just now.",
"greeting": "love,"
}

View file

@ -0,0 +1,9 @@
{
"subject": "[FreeSewing] Confirm your newsletter subscription",
"heading": "Please confirm your newsletter subscription",
"lead": "To confirm your subscription to the FreeSewing newsletter, click the big rectangle below:",
"text-lead": "To confirm your subscription to the FreeSewing newsletter, click the link below:",
"button": "Confirm subscription",
"closing": "You (or someone else) tried to subscribe this email address to the FreeSewing newsletter just now.",
"greeting": "love,"
}

View file

@ -0,0 +1,9 @@
{
"subject": "[FreeSewing] Confirm your newsletter subscription",
"heading": "Please confirm your newsletter subscription",
"lead": "To confirm your subscription to the FreeSewing newsletter, click the big rectangle below:",
"text-lead": "To confirm your subscription to the FreeSewing newsletter, click the link below:",
"button": "Confirm subscription",
"closing": "You (or someone else) tried to subscribe this email address to the FreeSewing newsletter just now.",
"greeting": "love,"
}

View file

@ -0,0 +1,25 @@
import { buttonRow, newsletterClosingRow, headingRow, lead1Row, wrap } from '../shared/blocks.mjs'
// Translations
import en from './en.json' assert { type: 'json' }
import de from './de.json' assert { type: 'json' }
import es from './es.json' assert { type: 'json' }
import fr from './fr.json' assert { type: 'json' }
import nl from './nl.json' assert { type: 'json' }
import uk from './uk.json' assert { type: 'json' }
export const nlsub = {
html: wrap.html(`
${headingRow.html}
${lead1Row.html}
${buttonRow.html}
${newsletterClosingRow.html}
`),
text: wrap.text(`
${headingRow.text}
${lead1Row.text}
${buttonRow.text}
${newsletterClosingRow.text}
`),
}
export const translations = { en, de, es, fr, nl, uk }

View file

@ -0,0 +1,9 @@
{
"subject": "[FreeSewing] Confirm your newsletter subscription",
"heading": "Please confirm your newsletter subscription",
"lead": "To confirm your subscription to the FreeSewing newsletter, click the big rectangle below:",
"text-lead": "To confirm your subscription to the FreeSewing newsletter, click the link below:",
"button": "Confirm subscription",
"closing": "You (or someone else) tried to subscribe this email address to the FreeSewing newsletter just now.",
"greeting": "love,"
}

View file

@ -0,0 +1,9 @@
{
"subject": "[FreeSewing] Confirm your newsletter subscription",
"heading": "Please confirm your newsletter subscription",
"lead": "To confirm your subscription to the FreeSewing newsletter, click the big rectangle below:",
"text-lead": "To confirm your subscription to the FreeSewing newsletter, click the link below:",
"button": "Confirm subscription",
"closing": "You (or someone else) tried to subscribe this email address to the FreeSewing newsletter just now.",
"greeting": "love,"
}

View file

@ -0,0 +1,6 @@
{
"subject": "[FreeSewing] You are already subscribed to our newsletter",
"heading": "You are already subscribed",
"closing": "You (or someone else) tried to subscribe this email address to the FreeSewing newsletter just now. But you are already subscribed, so there is nothing to do here.",
"greeting": "love,"
}

View file

@ -0,0 +1,6 @@
{
"subject": "[FreeSewing] You are already subscribed to our newsletter",
"heading": "You are already subscribed",
"closing": "You (or someone else) tried to subscribe this email address to the FreeSewing newsletter just now. But you are already subscribed, so there is nothing to do here.",
"greeting": "love,"
}

View file

@ -0,0 +1,6 @@
{
"subject": "[FreeSewing] You are already subscribed to our newsletter",
"heading": "You are already subscribed",
"closing": "You (or someone else) tried to subscribe this email address to the FreeSewing newsletter just now. But you are already subscribed, so there is nothing to do here.",
"greeting": "love,"
}

View file

@ -0,0 +1,6 @@
{
"subject": "[FreeSewing] You are already subscribed to our newsletter",
"heading": "You are already subscribed",
"closing": "You (or someone else) tried to subscribe this email address to the FreeSewing newsletter just now. But you are already subscribed, so there is nothing to do here.",
"greeting": "love,"
}

View file

@ -0,0 +1,23 @@
import { buttonRow, closingRow, headingRow, lead1Row, wrap } from '../shared/blocks.mjs'
// Translations
import en from './en.json' assert { type: 'json' }
import de from './de.json' assert { type: 'json' }
import es from './es.json' assert { type: 'json' }
import fr from './fr.json' assert { type: 'json' }
import nl from './nl.json' assert { type: 'json' }
import uk from './uk.json' assert { type: 'json' }
export const nlsubact = {
html: wrap.html(`
${headingRow.html}
${lead1Row.html}
${closingRow.html}
`),
text: wrap.text(`
${headingRow.text}
${lead1Row.text}
${closingRow.text}
`),
}
export const translations = { en, de, es, fr, nl, uk }

View file

@ -0,0 +1,6 @@
{
"subject": "[FreeSewing] You are already subscribed to our newsletter",
"heading": "You are already subscribed",
"closing": "You (or someone else) tried to subscribe this email address to the FreeSewing newsletter just now. But you are already subscribed, so there is nothing to do here.",
"greeting": "love,"
}

View file

@ -0,0 +1,6 @@
{
"subject": "[FreeSewing] You are already subscribed to our newsletter",
"heading": "You are already subscribed",
"closing": "You (or someone else) tried to subscribe this email address to the FreeSewing newsletter just now. But you are already subscribed, so there is nothing to do here.",
"greeting": "love,"
}

View file

@ -0,0 +1,9 @@
{
"subject": "[FreeSewing] Confirm your newsletter subscription",
"heading": "Please confirm your newsletter subscription",
"lead": "To confirm your subscription to the FreeSewing newsletter, click the big rectangle below:",
"text-lead": "To confirm your subscription to the FreeSewing newsletter, click the link below:",
"button": "Confirm subscription",
"closing": "You (or someone else) tried to subscribe this email address to the FreeSewing newsletter just now. (note that we already had a pending subscription for this address).",
"greeting": "love,"
}

View file

@ -0,0 +1,9 @@
{
"subject": "[FreeSewing] Confirm your newsletter subscription",
"heading": "Please confirm your newsletter subscription",
"lead": "To confirm your subscription to the FreeSewing newsletter, click the big rectangle below:",
"text-lead": "To confirm your subscription to the FreeSewing newsletter, click the link below:",
"button": "Confirm subscription",
"closing": "You (or someone else) tried to subscribe this email address to the FreeSewing newsletter just now. (note that we already had a pending subscription for this address).",
"greeting": "love,"
}

View file

@ -0,0 +1,9 @@
{
"subject": "[FreeSewing] Confirm your newsletter subscription",
"heading": "Please confirm your newsletter subscription",
"lead": "To confirm your subscription to the FreeSewing newsletter, click the big rectangle below:",
"text-lead": "To confirm your subscription to the FreeSewing newsletter, click the link below:",
"button": "Confirm subscription",
"closing": "You (or someone else) tried to subscribe this email address to the FreeSewing newsletter just now. (note that we already had a pending subscription for this address).",
"greeting": "love,"
}

View file

@ -0,0 +1,9 @@
{
"subject": "[FreeSewing] Confirm your newsletter subscription",
"heading": "Please confirm your newsletter subscription",
"lead": "To confirm your subscription to the FreeSewing newsletter, click the big rectangle below:",
"text-lead": "To confirm your subscription to the FreeSewing newsletter, click the link below:",
"button": "Confirm subscription",
"closing": "You (or someone else) tried to subscribe this email address to the FreeSewing newsletter just now. (note that we already had a pending subscription for this address).",
"greeting": "love,"
}

View file

@ -0,0 +1,25 @@
import { buttonRow, newsletterClosingRow, headingRow, lead1Row, wrap } from '../shared/blocks.mjs'
// Translations
import en from './en.json' assert { type: 'json' }
import de from './de.json' assert { type: 'json' }
import es from './es.json' assert { type: 'json' }
import fr from './fr.json' assert { type: 'json' }
import nl from './nl.json' assert { type: 'json' }
import uk from './uk.json' assert { type: 'json' }
export const nlsubinact = {
html: wrap.html(`
${headingRow.html}
${lead1Row.html}
${buttonRow.html}
${newsletterClosingRow.html}
`),
text: wrap.text(`
${headingRow.text}
${lead1Row.text}
${buttonRow.text}
${newsletterClosingRow.text}
`),
}
export const translations = { en, de, es, fr, nl, uk }

View file

@ -0,0 +1,9 @@
{
"subject": "[FreeSewing] Confirm your newsletter subscription",
"heading": "Please confirm your newsletter subscription",
"lead": "To confirm your subscription to the FreeSewing newsletter, click the big rectangle below:",
"text-lead": "To confirm your subscription to the FreeSewing newsletter, click the link below:",
"button": "Confirm subscription",
"closing": "You (or someone else) tried to subscribe this email address to the FreeSewing newsletter just now. (note that we already had a pending subscription for this address).",
"greeting": "love,"
}

View file

@ -0,0 +1,9 @@
{
"subject": "[FreeSewing] Confirm your newsletter subscription",
"heading": "Please confirm your newsletter subscription",
"lead": "To confirm your subscription to the FreeSewing newsletter, click the big rectangle below:",
"text-lead": "To confirm your subscription to the FreeSewing newsletter, click the link below:",
"button": "Confirm subscription",
"closing": "You (or someone else) tried to subscribe this email address to the FreeSewing newsletter just now. (note that we already had a pending subscription for this address).",
"greeting": "love,"
}

View file

@ -0,0 +1,7 @@
{
"subject": "[FreeSewing] Passwort zurücksetzen fixme",
"heading": "FIXME",
"lead": "fixme",
"text-lead": "fixme",
"closing": "fixme"
}

View file

@ -1,5 +0,0 @@
subject: '[FreeSewing] Passwort zurücksetzen fixme'
heading: FIXME
lead: fixme
text-lead: fixme
closing: fixme

View file

@ -0,0 +1,7 @@
{
"subject": "[FreeSewing] passwordreset fixme",
"heading": "FIXME",
"lead": "fixme",
"text-lead": "fixme",
"closing": "fixme"
}

View file

@ -1,5 +0,0 @@
subject: '[FreeSewing] passwordreset fixme'
heading: FIXME
lead: fixme
text-lead: fixme
closing: fixme

View file

@ -0,0 +1,7 @@
{
"subject": "[FreeSewing] passwordreset fixme",
"heading": "FIXME",
"lead": "fixme",
"text-lead": "fixme",
"closing": "fixme"
}

View file

@ -1,5 +0,0 @@
subject: '[FreeSewing] passwordreset fixme'
heading: FIXME
lead: fixme
text-lead: fixme
closing: fixme

View file

@ -0,0 +1,7 @@
{
"subject": "[FreeSewing] passwordreset fixme",
"heading": "FIXME",
"lead": "fixme",
"text-lead": "fixme",
"closing": "fixme"
}

View file

@ -1,5 +0,0 @@
subject: '[FreeSewing] passwordreset fixme'
heading: FIXME
lead: fixme
text-lead: fixme
closing: fixme

View file

@ -1,10 +1,11 @@
import { buttonRow, closingRow, headingRow, wrap } from '../shared/blocks.mjs'
// Translations
import en from '../../../../public/locales/en/passwordreset.json' assert { type: 'json' }
import de from '../../../../public/locales/de/passwordreset.json' assert { type: 'json' }
import es from '../../../../public/locales/es/passwordreset.json' assert { type: 'json' }
import fr from '../../../../public/locales/fr/passwordreset.json' assert { type: 'json' }
import nl from '../../../../public/locales/nl/passwordreset.json' assert { type: 'json' }
import en from './en.json' assert { type: 'json' }
import de from './de.json' assert { type: 'json' }
import es from './es.json' assert { type: 'json' }
import fr from './fr.json' assert { type: 'json' }
import nl from './nl.json' assert { type: 'json' }
import uk from './uk.json' assert { type: 'json' }
export const passwordreset = {
html: wrap.html(`
@ -34,4 +35,4 @@ ${closingRow.text}
`),
}
export const translations = { en, de, es, fr, nl }
export const translations = { en, de, es, fr, nl, uk }

View file

@ -0,0 +1,7 @@
{
"subject": "[FreeSewing] wachtwoord opnieuw instellen fixme",
"heading": "FIXME",
"lead": "fixme",
"text-lead": "fixme",
"closing": "fixme"
}

View file

@ -1,5 +0,0 @@
subject: '[FreeSewing] wachtwoord opnieuw instellen fixme'
heading: FIXME
lead: fixme
text-lead: fixme
closing: fixme

View file

@ -0,0 +1,7 @@
{
"subject": "[FreeSewing] виправлення скидання паролю",
"heading": "FIXME",
"lead": "fixme",
"text-lead": "fixme",
"closing": "fixme"
}

View file

@ -1,5 +0,0 @@
subject: '[FreeSewing] виправлення скидання паролю'
heading: FIXME
lead: fixme
text-lead: fixme
closing: fixme

View file

@ -41,6 +41,32 @@ joost
PS: {{{ text-ps }}} : {{{ supportUrl }}}`,
}
export const newsletterClosingRow = {
html: `
<tr>
<td align="left" class="sm-p-15px" style="padding-top: 30px">
<p style="margin: 0; font-size: 16px; line-height: 25px; color: #262626">
{{ closing }}
<br><br>
{{ greeting }}
<br>
joost
<br><br>
PS: {{ ps-pre-link}}
<a href="{{ psUrl }}" target="_blank" style="text-decoration: underline; color: #262626">
<b>{{ ps-link}}</b>
</a> {{ ps-post-link }}
</p>
</td>
</tr>`,
text: `
{{{ closing }}}
{{{ greeting }}}
joost
`,
}
export const headingRow = {
html: `
<tr>

Some files were not shown because too many files have changed in this diff Show more