1
0
Fork 0

wip(backend): Added HTML email

This commit is contained in:
joostdecock 2022-11-05 22:02:51 +01:00
parent d63b7e5397
commit 3fc08d8bdb
17 changed files with 2665 additions and 442 deletions

View file

@ -16,6 +16,7 @@
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"crypto": "^1.0.1", "crypto": "^1.0.1",
"express": "4.18.2", "express": "4.18.2",
"mustache": "^4.2.0",
"passport-http": "^0.3.0", "passport-http": "^0.3.0",
"pino": "^8.7.0" "pino": "^8.7.0"
}, },

View file

@ -24,6 +24,8 @@ model Confirmation {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
data String data String
type String type String
user User @relation(fields: [userId], references: [id])
userId Int
} }
model Subscriber { model Subscriber {
@ -49,6 +51,7 @@ model User {
password String password String
patron Int @default(0) patron Int @default(0)
apikeys Apikey[] apikeys Apikey[]
confirmations Confirmation[]
people Person[] people Person[]
patterns Pattern[] patterns Pattern[]
role String @default("user") role String @default("user")

Binary file not shown.

View file

@ -32,7 +32,7 @@ ApikeyController.prototype.whoami = async (req, res, tools) => {
// Load user making the call // Load user making the call
await User.loadAuthenticatedUser(req.user) await User.loadAuthenticatedUser(req.user)
const key = User.user.apikeys.filter((key) => key.id === req.user.id) const key = User.authenticatedUser.apikeys.filter((key) => key.id === req.user.id)
if (key.length === 1) if (key.length === 1)
Apikey.setResponse(200, 'success', { Apikey.setResponse(200, 'success', {

View file

@ -4,7 +4,6 @@ import { hash, hashPassword, randomString, verifyPassword } from '../utils/crypt
import { clean, asJson } from '../utils/index.mjs' import { clean, asJson } from '../utils/index.mjs'
import { getUserAvatar } from '../utils/sanity.mjs' import { getUserAvatar } from '../utils/sanity.mjs'
import { log } from '../utils/log.mjs' import { log } from '../utils/log.mjs'
import { emailTemplate } from '../utils/email.mjs'
import set from 'lodash.set' import set from 'lodash.set'
import { UserModel } from '../models/user.mjs' import { UserModel } from '../models/user.mjs'
@ -68,7 +67,7 @@ export function UserController() {}
*/ */
UserController.prototype.signup = async (req, res, tools) => { UserController.prototype.signup = async (req, res, tools) => {
const User = new UserModel(tools) const User = new UserModel(tools)
await User.create(req.body) await User.create(req)
return User.sendResponse(res) return User.sendResponse(res)
} }
@ -80,77 +79,10 @@ UserController.prototype.signup = async (req, res, tools) => {
* See: https://freesewing.dev/reference/backend/api * See: https://freesewing.dev/reference/backend/api
*/ */
UserController.prototype.confirm = async (req, res, tools) => { UserController.prototype.confirm = async (req, res, tools) => {
if (!req.params.id) return res.status(404).send({ error: 'missingConfirmationId', result }) const User = new UserModel(tools)
if (Object.keys(req.body) < 1) return res.status(400).json({ error: 'postBodyMissing', result }) await User.confirm(req)
if (!req.body.consent || req.body.consent < 1)
return res.status(400).send({ error: 'consentRequired', result })
// Destructure what we need from tools return User.sendResponse(res)
const { prisma, config, decrypt } = tools
// Retrieve confirmation record
let confirmation
try {
confirmation = await prisma.confirmation.findUnique({
where: {
id: req.params.id,
},
})
} catch (err) {
log.warn(err, `Could not lookup confirmation id ${req.params.id}`)
return res.status(404).send({ error: 'failedToRetrieveConfirmationId', result })
}
if (!confirmation) {
log.warn(err, `Could not find confirmation id ${req.params.id}`)
return res.status(404).send({ error: 'failedToFindConfirmationId', result })
}
if (confirmation.type !== 'signup') {
log.warn(err, `Confirmation mismatch; ${req.params.id} is not a signup id`)
return res.status(404).send({ error: 'confirmationIdTypeMismatch', result })
}
const data = decrypt(confirmation.data)
// Retrieve user account
let account
try {
account = await prisma.user.findUnique({
where: {
id: data.id,
},
})
} catch (err) {
log.warn(err, `Could not lookup user id ${data.id} from confirmation data`)
return res.status(404).send({ error: 'failedToRetrieveUserIdFromConfirmationData', result })
}
if (!account) {
log.warn(err, `Could not find user id ${data.id} from confirmation data`)
return res.status(404).send({ error: 'failedToLoadUserFromConfirmationData', result })
}
// Update user consent and status
let updateUser
try {
updateUser = await prisma.user.update({
where: {
id: account.id,
},
data: {
status: 1,
consent: req.body.consent,
lastLogin: new Date(),
},
})
} catch (err) {
log.warn(err, `Could not update user id ${data.id} after confirmation`)
return res.status(404).send({ error: 'failedToUpdateUserAfterConfirmation', result })
}
// Account is now active, let's return a passwordless login
return res.status(200).send({
result: 'success',
token: getToken(account, config),
account: asAccount({ ...account, status: 1, consent: req.body.consent }, decrypt),
})
} }
/* /*

View file

@ -8,8 +8,13 @@ export function ConfirmationModel(tools) {
return this return this
} }
ConfirmationModel.prototype.load = async function (where) { ConfirmationModel.prototype.read = async function (where) {
this.record = await this.prisma.confirmation.findUnique({ where }) this.record = await this.prisma.confirmation.findUnique({
where,
include: {
user: true,
},
})
return this.setExists() return this.setExists()
} }

View file

@ -1,8 +1,8 @@
import jwt from 'jsonwebtoken'
import { log } from '../utils/log.mjs' import { log } from '../utils/log.mjs'
import { hash, hashPassword, randomString, verifyPassword } from '../utils/crypto.mjs' import { hash, hashPassword, randomString, verifyPassword } from '../utils/crypto.mjs'
import { clean, asJson } from '../utils/index.mjs' import { clean, asJson, i18nUrl } from '../utils/index.mjs'
import { ConfirmationModel } from './confirmation.mjs' import { ConfirmationModel } from './confirmation.mjs'
import { emailTemplate } from '../utils/email.mjs'
export function UserModel(tools) { export function UserModel(tools) {
this.config = tools.config this.config = tools.config
@ -20,9 +20,10 @@ export function UserModel(tools) {
* *
* Stores result in this.record * Stores result in this.record
*/ */
UserModel.prototype.load = async function (where) { UserModel.prototype.read = async function (where) {
this.record = await this.prisma.user.findUnique({ where }) this.record = await this.prisma.user.findUnique({ where })
if (this.record?.email) this.email = this.decrypt(this.record.email) if (this.record?.email) this.email = this.decrypt(this.record.email)
if (this.record?.initial) this.initial = this.decrypt(this.record.initial)
return this.setExists() return this.setExists()
} }
@ -60,14 +61,14 @@ UserModel.prototype.setExists = function () {
/* /*
* Creates a user+confirmation and sends out signup email * Creates a user+confirmation and sends out signup email
*/ */
UserModel.prototype.create = async function (body) { UserModel.prototype.create = async function ({ body }) {
if (Object.keys(body) < 1) return this.setResponse(400, 'postBodyMissing') if (Object.keys(body) < 1) return this.setResponse(400, 'postBodyMissing')
if (!body.email) return this.setResponse(400, 'emailMissing') if (!body.email) return this.setResponse(400, 'emailMissing')
if (!body.password) return this.setResponse(400, 'passwordMissing') if (!body.password) return this.setResponse(400, 'passwordMissing')
if (!body.language) return this.setResponse(400, 'languageMissing') if (!body.language) return this.setResponse(400, 'languageMissing')
const ehash = hash(clean(body.email)) const ehash = hash(clean(body.email))
await this.load({ ehash }) await this.read({ ehash })
if (this.exists) return this.setResponse(400, 'emailExists') if (this.exists) return this.setResponse(400, 'emailExists')
try { try {
@ -100,9 +101,8 @@ UserModel.prototype.create = async function (body) {
}) })
} catch (err) { } catch (err) {
log.warn(err, 'Could not update username after user creation') log.warn(err, 'Could not update username after user creation')
return this.setResponse(500, 'error', 'usernameUpdateAfterUserCreationFailed') return this.setResponse(500, 'usernameUpdateAfterUserCreationFailed')
} }
log.info({ user: this.record.id }, 'Account created')
// Create confirmation // Create confirmation
this.confirmation = await this.Confirmation.create({ this.confirmation = await this.Confirmation.create({
@ -113,10 +113,20 @@ UserModel.prototype.create = async function (body) {
id: this.record.id, id: this.record.id,
ehash: ehash, ehash: ehash,
}), }),
userId: this.record.id,
}) })
// Send signup email // Send signup email
//await this.sendSignupEmail() await this.mailer.send({
template: 'signup',
language: this.language,
to: 'joost@decock.org', // this.email,
replacements: {
actionUrl: i18nUrl(this.language, `/confirm/signup/${this.Confirmation.record.id}`),
whyUrl: i18nUrl(this.language, `/docs/faq/email/why-signup`),
supportUrl: i18nUrl(this.language, `/patrons/join`),
},
})
return body.unittest && this.email.split('@').pop() === this.config.tests.domain return body.unittest && this.email.split('@').pop() === this.config.tests.domain
? this.setResponse(201, false, { email: this.email, confirmation: this.confirmation.record.id }) ? this.setResponse(201, false, { email: this.email, confirmation: this.confirmation.record.id })
@ -124,21 +134,52 @@ UserModel.prototype.create = async function (body) {
} }
/* /*
* Sends out signup email * Confirms a user account
* FIXME: Move to utils
*/ */
UserModel.prototype.sendSignupEmail = async function () { UserModel.prototype.confirm = async function ({ body, params }) {
try { if (!params.id) return this.setReponse(404, 'missingConfirmationId')
this.confirmationSent = await this.mailer.send( if (Object.keys(body) < 1) return this.setResponse(400, 'postBodyMissing')
this.email, if (!body.consent || typeof body.consent !== 'number' || body.consent < 1)
...emailTemplate.signup(this.email, this.language, this.confirmation) return this.setResponse(400, 'consentRequired')
)
} catch (err) { // Retrieve confirmation record
log.warn(err, 'Unable to send signup email') await this.Confirmation.read({ id: params.id })
return this.setResponse(500, 'error', 'unableToSendSignupEmail')
if (!this.Confirmation.exists) {
log.warn(err, `Could not find confirmation id ${params.id}`)
return this.setResponse(404, 'failedToFindConfirmationId')
} }
return this.setResponse(200) if (this.Confirmation.record.type !== 'signup') {
log.warn(err, `Confirmation mismatch; ${params.id} is not a signup id`)
return this.setResponse(404, 'confirmationIdTypeMismatch')
}
if (this.error) return this
const data = await this.decrypt(this.Confirmation.record.data)
if (data.ehash !== this.Confirmation.record.user.ehash)
return this.setResponse(404, 'confirmationEhashMismatch')
if (data.id !== this.Confirmation.record.user.id)
return this.setResponse(404, 'confirmationUserIdMismatch')
// Load user
await this.read({ id: this.Confirmation.record.user.id })
if (this.error) return this
// Update user status, consent, and last login
await this.update({
//data: this.encrypt({...this.decrypt(this.record.data), status: 1}),
consent: body.consent,
lastLogin: new Date(),
})
if (this.error) return this
// Account is now active, let's return a passwordless login
return this.setResponse(200, false, {
result: 'success',
token: this.getToken(),
account: this.asAccount(),
})
} }
/* /*
@ -153,12 +194,52 @@ UserModel.prototype.update = async function (data) {
} catch (err) { } catch (err) {
log.warn(err, 'Could not update user record') log.warn(err, 'Could not update user record')
process.exit() process.exit()
return this.setResponse(500, 'error', 'updateUserFailed') return this.setResponse(500, 'updateUserFailed')
} }
return this.setResponse(200) return this.setResponse(200)
} }
/*
* Returns account data
*/
UserModel.prototype.asAccount = function () {
return {
id: this.record.id,
consent: this.record.consent,
createdAt: this.record.createdAt,
data: this.record.data,
email: this.email,
initial: this.initial,
lastLogin: this.record.lastLogin,
newsletter: this.record.newsletter,
patron: this.record.patron,
role: this.record.role,
status: this.record.status,
updatedAt: this.record.updatedAt,
username: this.record.username,
lusername: this.record.lusername,
}
}
/*
* Returns a JSON Web Token (jwt)
*/
UserModel.prototype.getToken = function () {
return jwt.sign(
{
_id: this.record.id,
username: this.record.username,
role: this.record.role,
status: this.record.status,
aud: this.config.jwt.audience,
iss: this.config.jwt.issuer,
},
this.config.jwt.secretOrKey,
{ expiresIn: this.config.jwt.expiresIn }
)
}
/* /*
* Helper method to set the response code, result, and body * Helper method to set the response code, result, and body
* *

View file

@ -1,8 +1,3 @@
/*
* buttonRow uses the following replacements:
* - actionUrl
* - button
*/
export const buttonRow = { export const buttonRow = {
html: ` html: `
<tr> <tr>
@ -16,25 +11,17 @@ export const buttonRow = {
</table> </table>
</td> </td>
</tr>`, </tr>`,
text: `{{ actionUrl }}`, text: `{{{ actionUrl }}}`,
} }
/*
* closingRow uses the following replacements:
* - closing
* - greeting
* - ps-pre-link
* - ps-link
* - ps-post-link
*/
export const closingRow = { export const closingRow = {
html: ` html: `
<tr> <tr>
<td align="left" class="sm-p-15px" style="padding-top: 30px"> <td align="left" class="sm-p-15px" style="padding-top: 30px">
<p style="margin: 0; font-size: 16px; line-height: 25px; color: #262626"> <p style="margin: 0; font-size: 16px; line-height: 25px; color: #262626">
{{ closing }}. {{ closing }}
<br><br> <br><br>
{{ greeting }}, {{ greeting }}
<br> <br>
joost joost
<br><br> <br><br>
@ -42,7 +29,7 @@ export const closingRow = {
PS: {{ ps-pre-link}} PS: {{ ps-pre-link}}
<a href="{{ supportUrl }}" target="_blank" style="text-decoration: none; color: #262626"> <a href="{{ supportUrl }}" target="_blank" style="text-decoration: none; color: #262626">
<b>{{ ps-link}}</b> <b>{{ ps-link}}</b>
</a> {{ ps-post-link }}. </a> {{ ps-post-link }}
</small> </small>
</p> </p>
</td> </td>
@ -53,14 +40,9 @@ export const closingRow = {
{{ greeting }} {{ greeting }}
joost joost
PS: {{ text-ps }} : {{ text-ps-link }}`, PS: {{ text-ps }} : {{{ text-ps-link }}}`,
} }
/*
* headingRow uses the following replacements:
* - actionUrl
* - heading
*/
export const headingRow = { export const headingRow = {
html: ` html: `
<tr> <tr>
@ -77,11 +59,6 @@ export const headingRow = {
`, `,
} }
/*
* lead1Row uses the following replacements:
* - actionUrl
* - lead
*/
export const lead1Row = { export const lead1Row = {
html: ` html: `
<tr> <tr>
@ -94,20 +71,10 @@ export const lead1Row = {
</td> </td>
</tr>`, </tr>`,
text: `{{ textLead }} text: `{{ textLead }}
{{ actionUrl }} {{{ actionUrl }}}
`, `,
} }
/*
* Helper methods to wrap the body with all it takes
* Uses the following replacements:
* - title
* - intro
* - body
* - urlWebsite
* - urlWhy
* - whyDidIGetThis
*/
export const wrap = { export const wrap = {
html: (body) => `<!DOCTYPE html> html: (body) => `<!DOCTYPE html>
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml"> <html lang="en" xmlns:v="urn:schemas-microsoft-com:vml">
@ -260,10 +227,33 @@ Plantin en Moretuslei 69
Antwerp Antwerp
Belgium Belgium
{{ website }} : {{ urlWebsite }} {{ website }} : {{{ urlWebsite }}}
Github : https://github.com/fresewing/freesewing Github : https://github.com/freesewing/freesewing
Discord : https://discord.freesewing.org/ Discord : https://discord.freesewing.org/
Twitter : https://twitter.com/freesewing_org Twitter : https://twitter.com/freesewing_org
{{ whyDidIGetThis }} : {{ urlWhy }} {{ whyDidIGetThis }} : {{{ whyUrl }}}
`, `,
} }
export const translations = {
en: {
whyDidIGetThis: 'Why did I get this email?',
website: 'freesewing.org',
},
de: {
whyDidIGetThis: 'Why did I get this?', // FIXME: Provide German translation
website: 'freesewing.org/de',
},
es: {
whyDidIGetThis: 'Why did I get this?', // FIXME: Provide Spanish translation
website: 'freesewing.org/es',
},
fr: {
whyDidIGetThis: 'Why did I get this?', // FIXME: Provide French translation
website: 'freesewing.org/fr',
},
nl: {
whyDidIGetThis: 'Waarom kreeg ik deze email?',
website: 'freesewing.org/nl',
},
}

View file

@ -1,17 +1,6 @@
import { buttonRow, closingRow, headingRow, lead1Row, wrap } from './blocks.mjs' import { buttonRow, closingRow, headingRow, lead1Row, wrap } from './blocks.mjs'
import { translations as sharedTranslations } from './blocks.mjs'
/*
* Used the following replacements:
* - actionUrl
* - heading
* - lead
* - button
* - closing
* - greeting
* - ps-pre-link
* - ps-link
* - ps-post-link
*/
export const emailChange = { export const emailChange = {
html: wrap.html(` html: wrap.html(`
${headingRow.html} ${headingRow.html}
@ -21,3 +10,64 @@ export const emailChange = {
`), `),
text: wrap.text(`${headingRow.text}${lead1Row.text}${buttonRow.text}${closingRow.text}`), text: wrap.text(`${headingRow.text}${lead1Row.text}${buttonRow.text}${closingRow.text}`),
} }
export const translations = {
en: {
heading: 'Does this new E-mail address work?',
lead: 'To confirm your new E-mail address, click the big black rectangle below:',
button: 'Confirm E-mail change',
closing: "That's all it takes.",
greeting: 'love',
'ps-pre-link': 'FreeSewing is free (duh), but please',
'ps-link': 'become a patron',
'ps-post-link': 'if you cxan afford it.',
...sharedTranslations.en,
},
// FIXME: Translate German
de: {
heading: 'Does this new E-mail address work?',
lead: 'To confirm your new E-mail address, click the big black rectangle below:',
button: 'Confirm E-mail change',
closing: "That's all it takes.",
greeting: 'love',
'ps-pre-link': 'FreeSewing is free (duh), but please',
'ps-link': 'become a patron',
'ps-post-link': 'if you cxan afford it.',
...sharedTranslations.de,
},
// FIXME: Translate Spanish
es: {
heading: 'Does this new E-mail address work?',
lead: 'To confirm your new E-mail address, click the big black rectangle below:',
button: 'Confirm E-mail change',
closing: "That's all it takes.",
greeting: 'love',
'ps-pre-link': 'FreeSewing is free (duh), but please',
'ps-link': 'become a patron',
'ps-post-link': 'if you cxan afford it.',
...sharedTranslations.es,
},
// FIXME: Translate French
fr: {
heading: 'Does this new E-mail address work?',
lead: 'To confirm your new E-mail address, click the big black rectangle below:',
button: 'Confirm E-mail change',
closing: "That's all it takes.",
greeting: 'love',
'ps-pre-link': 'FreeSewing is free (duh), but please',
'ps-link': 'become a patron',
'ps-post-link': 'if you cxan afford it.',
...sharedTranslations.fr,
},
nl: {
heading: 'Werkt dit E-mail adres?',
lead: 'Om je E-mail wijziging te bevestigen moet je op de grote zwarte rechthoek hieronder te klikken:',
button: 'Bevestig je E-mail wijziging',
closing: 'Dat is al wat je moet doen.',
greeting: 'liefs',
'ps-pre-link': 'FreeSewing is gratis (echt), maar gelieve',
'ps-link': 'ons werk te ondersteunen',
'ps-post-link': 'als het even kan.',
...sharedTranslations.nl,
},
}

View file

@ -1,4 +1,5 @@
import { headingRow, wrap } from './blocks.mjs' import { headingRow, wrap } from './blocks.mjs'
import { translations as sharedTranslations } from './blocks.mjs'
/* /*
* Used the following replacements: * Used the following replacements:
@ -36,3 +37,5 @@ joost
PS: {{ps}}`), PS: {{ps}}`),
} }
export const translations = {}

View file

@ -1,9 +1,9 @@
import { emailChange } from './emailchange.mjs' import { emailChange, translations as emailChangeTranslations } from './emailchange.mjs'
import { goodbye } from './goodbye.mjs' import { goodbye, translations as goodbyeTranslations } from './goodbye.mjs'
import { loginLink } from './loginlink.mjs' import { loginLink, translations as loginLinkTranslations } from './loginlink.mjs'
import { newsletterSub } from './newslettersub.mjs' import { newsletterSub, translations as newsletterSubTranslations } from './newslettersub.mjs'
import { passwordReset } from './passwordreset.mjs' import { passwordReset, translations as passwordResetTranslations } from './passwordreset.mjs'
import { signup } from './signup.mjs' import { signup, translations as signupTranslations } from './signup.mjs'
export const templates = { export const templates = {
emailChange, emailChange,
@ -13,3 +13,16 @@ export const templates = {
passwordReset, passwordReset,
signup, signup,
} }
/*
* This is not part of our i18n package for... reasons
* It's not an accident, let's put it that way.
*/
export const translations = {
emailChange: emailChangeTranslations,
goodbye: goodbyeTranslations,
loginLink: loginLinkTranslations,
newsletterSub: newsletterSubTranslations,
passwordReset: passwordResetTranslations,
signup: signupTranslations,
}

View file

@ -1,4 +1,5 @@
import { buttonRow, closingRow, headingRow, lead1Row, wrap } from './blocks.mjs' import { buttonRow, closingRow, headingRow, lead1Row, wrap } from './blocks.mjs'
import { translations as sharedTranslations } from './blocks.mjs'
/* /*
* Used the following replacements: * Used the following replacements:
@ -37,3 +38,5 @@ ${buttonRow.text}
${closingRow.text} ${closingRow.text}
`), `),
} }
export const translations = {}

View file

@ -1,4 +1,5 @@
import { buttonRow, closingRow, headingRow, lead1Row, wrap } from './blocks.mjs' import { buttonRow, closingRow, headingRow, lead1Row, wrap } from './blocks.mjs'
import { translations as sharedTranslations } from './blocks.mjs'
/* /*
* Used the following replacements: * Used the following replacements:
@ -26,3 +27,5 @@ ${buttonRow.text}
${closingRow.text} ${closingRow.text}
`), `),
} }
export const translations = {}

View file

@ -1,4 +1,5 @@
import { buttonRow, closingRow, headingRow, lead1Row, wrap } from './blocks.mjs' import { buttonRow, closingRow, headingRow, lead1Row, wrap } from './blocks.mjs'
import { translations as sharedTranslations } from './blocks.mjs'
/* /*
* Used the following replacements: * Used the following replacements:
@ -39,3 +40,5 @@ ${buttonRow.text}
${closingRow.text} ${closingRow.text}
`), `),
} }
export const translations = {}

View file

@ -1,4 +1,5 @@
import { buttonRow, closingRow, headingRow, lead1Row, wrap } from './blocks.mjs' import { buttonRow, closingRow, headingRow, lead1Row, wrap } from './blocks.mjs'
import { translations as sharedTranslations } from './blocks.mjs'
/* /*
* Used the following replacements: * Used the following replacements:
@ -26,3 +27,69 @@ export const signup = {
${closingRow.text} ${closingRow.text}
`), `),
} }
export const translations = {
en: {
subject: '[FreeSewing] Confirm your E-mail address to activate your account',
heading: 'Welcome to FreeSewing',
lead: 'To activate your account, click the big black rectangle below:',
button: 'Activate account',
closing: "That's all for now.",
greeting: 'love',
'ps-pre-link': 'FreeSewing is free (duh), but please',
'ps-link': 'become a patron',
'ps-post-link': 'if you cxan afford it.',
...sharedTranslations.en,
},
// FIXME: Translate German
de: {
subject: '[FreeSewing] Confirm your E-mail address to activate your account',
heading: 'Welcome to FreeSewing',
lead: 'To activate your account, click the big black rectangle below:',
button: 'Activate account',
closing: "That's all for now.",
greeting: 'love',
'ps-pre-link': 'FreeSewing is free (duh), but please',
'ps-link': 'become a patron',
'ps-post-link': 'if you cxan afford it.',
...sharedTranslations.de,
},
// FIXME: Translate Spanish
es: {
subject: '[FreeSewing] Confirm your E-mail address to activate your account',
heading: 'Welcome to FreeSewing',
lead: 'To activate your account, click the big black rectangle below:',
button: 'Activate account',
closing: "That's all for now.",
greeting: 'love',
'ps-pre-link': 'FreeSewing is free (duh), but please',
'ps-link': 'become a patron',
'ps-post-link': 'if you cxan afford it.',
...sharedTranslations.es,
},
// FIXME: Translate French
fr: {
subject: '[FreeSewing] Confirm your E-mail address to activate your account',
heading: 'Welcome to FreeSewing',
lead: 'To activate your account, click the big black rectangle below:',
button: 'Activate account',
closing: "That's all for now.",
greeting: 'love',
'ps-pre-link': 'FreeSewing is free (duh), but please',
'ps-link': 'become a patron',
'ps-post-link': 'if you can afford it.',
...sharedTranslations.fr,
},
nl: {
subject: '[FreeSewing] Bevestig je E-mail adres om je account te activeren',
heading: 'Welkom bij FreeSewing',
lead: 'Om je account te activeren moet je op de grote zwarte rechthoek hieronder te klikken:',
button: 'Account activeren',
closing: 'Daarmee is dat ook weer geregeld.',
greeting: 'liefs',
'ps-pre-link': 'FreeSewing is gratis (echt), maar gelieve',
'ps-link': 'ons werk te ondersteunen',
'ps-post-link': 'als het even kan.',
...sharedTranslations.nl,
},
}

View file

@ -1,188 +1,6 @@
import axios from 'axios' import { templates, translations } from '../templates/email/index.mjs'
import { templates } from '../templates/email/index.mjs'
// FIXME: Update this after we re-structure the i18n package
import en from '../../../../packages/i18n/dist/en/email.mjs'
import nl from '../../../../packages/i18n/dist/en/email.mjs'
import fr from '../../../../packages/i18n/dist/en/email.mjs'
import es from '../../../../packages/i18n/dist/en/email.mjs'
import de from '../../../../packages/i18n/dist/en/email.mjs'
import { i18nUrl } from './index.mjs'
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2' import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2'
import mustache from 'mustache'
const i18n = { en, nl, fr, es, de }
export const emailTemplate = {
signup: (to, language, uuid) => [
i18n[language].signupTitle,
templates.signup(i18n[language], to, i18nUrl(language, `/confirm/signup/${uuid}`)),
],
}
emailTemplate.emailchange = (newAddress, currentAddress, language, id) => {
let html = loadTemplate('emailchange', 'html', language)
let text = loadTemplate('emailchange', 'text', language)
let from = [
'__emailchangeActionLink__',
'__emailchangeActionText__',
'__emailchangeTitle__',
'__emailchangeCopy1__',
'__headerOpeningLine__',
'__hiddenIntro__',
'__footerWhy__',
'__questionsJustReply__',
'__signature__',
]
let to = [
createUrl(language, `/confirm/email/${id}`),
i18n[language]['email.emailchangeActionText'],
i18n[language]['email.emailchangeTitle'],
i18n[language]['email.emailchangeCopy1'],
i18n[language]['email.emailchangeHeaderOpeningLine'],
i18n[language]['email.emailchangeHiddenIntro'],
i18n[language]['email.emailchangeWhy'],
i18n[language]['email.questionsJustReply'],
i18n[language]['email.signature'],
]
html = replace(html, from, to)
text = replace(text, from, to)
let options = {
from: `"${i18n[language]['email.joostFromFreesewing']}" <info@freesewing.org>`,
to: newAddress,
cc: currentAddress,
subject: i18n[language]['email.emailchangeSubject'],
headers: {
'X-Freesewing-Confirmation-ID': '' + id,
},
text,
html,
}
deliver(options, (error, info) => {
if (error) return console.log(error)
console.log('Message sent', info)
})
}
emailTemplate.passwordreset = (recipient, language, id) => {
let html = loadTemplate('passwordreset', 'html', language)
let text = loadTemplate('passwordreset', 'text', language)
let from = [
'__passwordresetActionLink__',
'__headerOpeningLine__',
'__hiddenIntro__',
'__footerWhy__',
]
let to = [
createUrl(language, `/confirm/reset/${id}`),
i18n[language]['email.passwordresetHeaderOpeningLine'],
i18n[language]['email.passwordresetHiddenIntro'],
i18n[language]['email.passwordresetWhy'],
]
html = replace(html, from, to)
text = replace(text, from, to)
let options = {
from: `"${i18n[language]['email.joostFromFreesewing']}" <info@freesewing.org>`,
to: recipient,
subject: i18n[language]['email.passwordresetSubject'],
headers: {
'X-Freesewing-Confirmation-ID': '' + id,
},
text,
html,
}
deliver(options, (error, info) => {
if (error) return console.log(error)
console.log('Message sent', info)
})
}
emailTemplate.goodbye = async (recipient, language) => {
let html = loadTemplate('goodbye', 'html', language)
let text = loadTemplate('goodbye', 'text', language)
let from = ['__headerOpeningLine__', '__hiddenIntro__', '__footerWhy__']
let to = [
i18n[language]['email.goodbyeHeaderOpeningLine'],
i18n[language]['email.goodbyeHiddenIntro'],
i18n[language]['email.goodbyeWhy'],
]
html = replace(html, from, to)
text = replace(text, from, to)
let options = {
from: `"${i18n[language]['email.joostFromFreesewing']}" <info@freesewing.org>`,
to: recipient,
subject: i18n[language]['email.goodbyeSubject'],
text,
html,
}
deliver(options, (error, info) => {
if (error) return console.log(error)
console.log('Message sent', info)
})
}
emailTemplate.subscribe = async (recipient, token) => {
let html = loadTemplate('newsletterSubscribe', 'html', 'en')
let text = loadTemplate('newsletterSubscribe', 'text', 'en')
let from = [
'__hiddenIntro__',
'__headerOpeningLine__',
'__newsletterConfirmationLink__',
'__footerWhy__',
]
let to = [
'Confirm your subscription to the FreeSewing newsletter',
'Please confirm it was you who requested this',
`https://backend.freesewing.org/newsletter/confirm/${token}`,
`You received this email because somebody tried to subscribe ${recipient} to the FreeSewing newsletter`,
]
html = replace(html, from, to)
text = replace(text, from, to)
let options = {
from: `"FreeSewing" <newsletter@freesewing.org>`,
to: recipient,
subject: 'Confirm your subscription to the FreeSewing newsletter',
text,
html,
}
deliver(options, (error, info) => {
if (error) return console.log(error)
console.log('Message sent', info)
})
}
emailTemplate.newsletterWelcome = async (recipient, ehash) => {
let html = loadTemplate('newsletterWelcome', 'html', 'en')
let text = loadTemplate('newsletterWelcome', 'text', 'en')
let from = [
'__hiddenIntro__',
'__headerOpeningLine__',
'__newsletterUnsubscribeLink__',
'__footerWhy__',
]
let to = [
'No action required; This is just an FYI',
"You're in. Now what?",
`https://backend.freesewing.org/newsletter/unsubscribe/${ehash}`,
`You received this email because you subscribed to the FreeSewing newsletter`,
]
html = replace(html, from, to)
text = replace(text, from, to)
let options = {
from: `"FreeSewing" <newsletter@freesewing.org>`,
to: recipient,
subject: 'Welcome to the FreeSewing newsletter',
text,
html,
}
deliver(options, (error, info) => {
if (error) return console.log(error)
console.log('Message sent', info)
})
}
/* /*
* Exporting this closure that makes sure we have access to the * Exporting this closure that makes sure we have access to the
@ -190,7 +8,7 @@ emailTemplate.newsletterWelcome = async (recipient, ehash) => {
*/ */
export const mailer = (config) => ({ export const mailer = (config) => ({
email: { email: {
send: (...params) => sendEmailViaAwsSes(config, ...params), send: (params) => sendEmailViaAwsSes(config, params),
}, },
}) })
@ -200,19 +18,37 @@ export const mailer = (config) => ({
* If you want to use another way to send email, change the mailer * If you want to use another way to send email, change the mailer
* assignment above to point to another method to deliver email * assignment above to point to another method to deliver email
*/ */
async function sendEmailViaAwsSes(config, to, subject, text) { async function sendEmailViaAwsSes(config, { template, to, language = 'en', replacements = {} }) {
// Make sure we have what it takes
if (!template || !to || typeof templates[template] === 'undefined') return false
// Load template
const { html, text } = templates[template]
const replace = {
...translations[template][language],
...replacements,
}
// IMHO the AWS apis are a complete clusterfuck // IMHO the AWS apis are a complete clusterfuck
// can't even use them without their garbage SDK
const Charset = 'utf-8'
const client = new SESv2Client({ region: config.aws.ses.region }) const client = new SESv2Client({ region: config.aws.ses.region })
const command = new SendEmailCommand({ const command = new SendEmailCommand({
ConfigurationSetName: 'backend', ConfigurationSetName: 'backend',
Content: { Content: {
Simple: { Simple: {
Body: { Body: {
Text: { Charset, Data: text }, Text: {
Charset: 'utf-8',
Data: mustache.render(text, replace),
},
Html: {
Charset: 'utf-8',
Data: mustache.render(html, replace),
},
},
Subject: {
Charset: 'utf-8',
Data: replace.subject,
}, },
Subject: { Charset, Data: subject },
}, },
}, },
Destination: { Destination: {
@ -224,7 +60,13 @@ async function sendEmailViaAwsSes(config, to, subject, text) {
FromEmailAddress: config.aws.ses.from, FromEmailAddress: config.aws.ses.from,
ReplyToAddresses: config.aws.ses.replyTo || [], ReplyToAddresses: config.aws.ses.replyTo || [],
}) })
const result = await client.send(command) let result
try {
result = await client.send(command)
} catch (err) {
console.log(err)
return false
}
return result['$metadata']?.httpStatusCode === 200 return result['$metadata']?.httpStatusCode === 200
} }

2377
yarn.lock

File diff suppressed because it is too large Load diff