2023-08-08 00:50:01 +00:00
|
|
|
|
import { createHash } from 'node:crypto'
|
2021-03-25 19:29:24 +00:00
|
|
|
|
import NextAuth from 'next-auth'
|
2023-07-29 19:38:20 +00:00
|
|
|
|
import CredentialsProvider from 'next-auth/providers/credentials'
|
|
|
|
|
import GitHubProvider from 'next-auth/providers/github'
|
|
|
|
|
import TwitterProvider from 'next-auth/providers/twitter'
|
|
|
|
|
import EmailProvider from 'next-auth/providers/email'
|
2024-03-20 00:37:31 +00:00
|
|
|
|
import prisma from '@/api/models'
|
2022-03-10 22:47:00 +00:00
|
|
|
|
import nodemailer from 'nodemailer'
|
2023-07-29 19:38:20 +00:00
|
|
|
|
import { PrismaAdapter } from '@auth/prisma-adapter'
|
2024-09-12 18:05:11 +00:00
|
|
|
|
import { NodeNextRequest, NodeNextResponse } from 'next/dist/server/base-http/node'
|
|
|
|
|
import { getToken, encode as encodeJWT } from 'next-auth/jwt'
|
|
|
|
|
import { datePivot } from '@/lib/time'
|
2023-08-08 00:50:01 +00:00
|
|
|
|
import { schnorr } from '@noble/curves/secp256k1'
|
2024-03-20 00:37:31 +00:00
|
|
|
|
import { notifyReferral } from '@/lib/webPush'
|
2024-05-04 23:06:15 +00:00
|
|
|
|
import { hashEmail } from '@/lib/crypto'
|
2024-10-12 22:08:37 +00:00
|
|
|
|
import * as cookie from 'cookie'
|
2023-07-11 00:20:38 +00:00
|
|
|
|
|
2024-02-14 19:33:31 +00:00
|
|
|
|
/**
|
|
|
|
|
* Stores userIds in user table
|
|
|
|
|
* @returns {Partial<import('next-auth').EventCallbacks>}
|
|
|
|
|
* */
|
|
|
|
|
function getEventCallbacks () {
|
|
|
|
|
return {
|
|
|
|
|
async linkAccount ({ user, profile, account }) {
|
|
|
|
|
if (account.provider === 'github') {
|
|
|
|
|
await prisma.user.update({ where: { id: user.id }, data: { githubId: profile.name } })
|
|
|
|
|
} else if (account.provider === 'twitter') {
|
|
|
|
|
await prisma.user.update({ where: { id: user.id }, data: { twitterId: profile.name } })
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
async signIn ({ user, profile, account, isNewUser }) {
|
|
|
|
|
if (account.provider === 'github') {
|
|
|
|
|
await prisma.user.update({ where: { id: user.id }, data: { githubId: profile.name } })
|
|
|
|
|
} else if (account.provider === 'twitter') {
|
|
|
|
|
await prisma.user.update({ where: { id: user.id }, data: { twitterId: profile.name } })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-07-07 16:12:02 +00:00
|
|
|
|
async function getReferrerId (referrer) {
|
|
|
|
|
try {
|
|
|
|
|
if (referrer.startsWith('item-')) {
|
|
|
|
|
return (await prisma.item.findUnique({ where: { id: parseInt(referrer.slice(5)) } }))?.userId
|
|
|
|
|
} else if (referrer.startsWith('profile-')) {
|
|
|
|
|
return (await prisma.user.findUnique({ where: { name: referrer.slice(8) } }))?.id
|
|
|
|
|
} else if (referrer.startsWith('territory-')) {
|
|
|
|
|
return (await prisma.sub.findUnique({ where: { name: referrer.slice(10) } }))?.userId
|
|
|
|
|
} else {
|
|
|
|
|
return (await prisma.user.findUnique({ where: { name: referrer } }))?.id
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('error getting referrer id', error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-02-14 19:33:31 +00:00
|
|
|
|
/** @returns {Partial<import('next-auth').CallbacksOptions>} */
|
2024-09-12 18:05:11 +00:00
|
|
|
|
function getCallbacks (req, res) {
|
2023-07-29 19:38:20 +00:00
|
|
|
|
return {
|
2021-06-27 03:09:39 +00:00
|
|
|
|
/**
|
|
|
|
|
* @param {object} token Decrypted JSON Web Token
|
|
|
|
|
* @param {object} user User object (only available on sign in)
|
|
|
|
|
* @param {object} account Provider account (only available on sign in)
|
|
|
|
|
* @param {object} profile Provider profile (only available on sign in)
|
|
|
|
|
* @param {boolean} isNewUser True if new user (only available on sign in)
|
|
|
|
|
* @return {object} JSON Web Token that will be saved
|
|
|
|
|
*/
|
2023-07-29 19:38:20 +00:00
|
|
|
|
async jwt ({ token, user, account, profile, isNewUser }) {
|
|
|
|
|
if (user) {
|
|
|
|
|
// token won't have an id on it for new logins, we add it
|
|
|
|
|
// note: token is what's kept in the jwt
|
2022-06-02 22:55:23 +00:00
|
|
|
|
token.id = Number(user.id)
|
2023-07-29 19:38:20 +00:00
|
|
|
|
|
2022-12-19 22:27:52 +00:00
|
|
|
|
// if referrer exists, set on user
|
2024-04-03 01:25:35 +00:00
|
|
|
|
// isNewUser doesn't work for nostr/lightning auth because we create the user before nextauth can
|
|
|
|
|
// this means users can update their referrer if they don't have one, which is fine
|
2022-12-19 22:27:52 +00:00
|
|
|
|
if (req.cookies.sn_referrer && user?.id) {
|
2024-07-07 16:12:02 +00:00
|
|
|
|
const referrerId = await getReferrerId(req.cookies.sn_referrer)
|
|
|
|
|
if (referrerId && referrerId !== parseInt(user?.id)) {
|
|
|
|
|
await prisma.user.updateMany({ where: { id: user.id, referrerId: null }, data: { referrerId } })
|
|
|
|
|
notifyReferral(referrerId)
|
2022-12-19 22:27:52 +00:00
|
|
|
|
}
|
|
|
|
|
}
|
2024-04-03 01:25:35 +00:00
|
|
|
|
}
|
2022-12-19 22:27:52 +00:00
|
|
|
|
|
2024-04-03 01:25:35 +00:00
|
|
|
|
if (token?.id) {
|
|
|
|
|
// HACK token.sub is used by nextjs v4 internally and is used like a userId
|
|
|
|
|
// setting it here allows us to link multiple auth method to an account
|
|
|
|
|
// ... in v3 this linking field was token.user.id
|
|
|
|
|
token.sub = Number(token.id)
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-12 18:05:11 +00:00
|
|
|
|
// response is only defined during signup/login
|
|
|
|
|
if (req && res) {
|
|
|
|
|
req = new NodeNextRequest(req)
|
|
|
|
|
res = new NodeNextResponse(res)
|
|
|
|
|
const secret = process.env.NEXTAUTH_SECRET
|
|
|
|
|
const jwt = await encodeJWT({ token, secret })
|
|
|
|
|
const me = await prisma.user.findUnique({ where: { id: token.id } })
|
|
|
|
|
setMultiAuthCookies(req, res, { ...me, jwt })
|
|
|
|
|
}
|
|
|
|
|
|
2021-06-27 03:09:39 +00:00
|
|
|
|
return token
|
|
|
|
|
},
|
2023-07-29 19:38:20 +00:00
|
|
|
|
async session ({ session, token }) {
|
|
|
|
|
// note: this function takes the current token (result of running jwt above)
|
|
|
|
|
// and returns a new object session that's returned whenever get|use[Server]Session is called
|
|
|
|
|
session.user.id = token.id
|
|
|
|
|
|
2021-06-27 03:09:39 +00:00
|
|
|
|
return session
|
|
|
|
|
}
|
2023-07-29 19:38:20 +00:00
|
|
|
|
}
|
|
|
|
|
}
|
2023-01-18 18:49:20 +00:00
|
|
|
|
|
2024-09-12 18:05:11 +00:00
|
|
|
|
function setMultiAuthCookies (req, res, { id, jwt, name, photoId }) {
|
|
|
|
|
const b64Encode = obj => Buffer.from(JSON.stringify(obj)).toString('base64')
|
|
|
|
|
const b64Decode = s => JSON.parse(Buffer.from(s, 'base64'))
|
|
|
|
|
|
|
|
|
|
// default expiration for next-auth JWTs is in 1 month
|
|
|
|
|
const expiresAt = datePivot(new Date(), { months: 1 })
|
2024-09-13 18:00:16 +00:00
|
|
|
|
const secure = process.env.NODE_ENV === 'production'
|
2024-09-12 18:05:11 +00:00
|
|
|
|
const cookieOptions = {
|
|
|
|
|
path: '/',
|
|
|
|
|
httpOnly: true,
|
2024-09-13 17:27:52 +00:00
|
|
|
|
secure,
|
2024-09-12 18:05:11 +00:00
|
|
|
|
sameSite: 'lax',
|
|
|
|
|
expires: expiresAt
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// add JWT to **httpOnly** cookie
|
|
|
|
|
res.appendHeader('Set-Cookie', cookie.serialize(`multi_auth.${id}`, jwt, cookieOptions))
|
|
|
|
|
|
|
|
|
|
let newMultiAuth = [{ id, name, photoId }]
|
|
|
|
|
if (req.cookies.multi_auth) {
|
|
|
|
|
const oldMultiAuth = b64Decode(req.cookies.multi_auth)
|
|
|
|
|
// make sure we don't add duplicates
|
|
|
|
|
if (oldMultiAuth.some(({ id: id_ }) => id_ === id)) return
|
|
|
|
|
newMultiAuth = [...oldMultiAuth, ...newMultiAuth]
|
|
|
|
|
}
|
|
|
|
|
res.appendHeader('Set-Cookie', cookie.serialize('multi_auth', b64Encode(newMultiAuth), { ...cookieOptions, httpOnly: false }))
|
|
|
|
|
|
|
|
|
|
// switch to user we just added
|
|
|
|
|
res.appendHeader('Set-Cookie', cookie.serialize('multi_auth.user-id', id, { ...cookieOptions, httpOnly: false }))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function pubkeyAuth (credentials, req, res, pubkeyColumnName) {
|
2023-07-29 19:38:20 +00:00
|
|
|
|
const { k1, pubkey } = credentials
|
2024-09-12 18:05:11 +00:00
|
|
|
|
|
|
|
|
|
// are we trying to add a new account for switching between?
|
|
|
|
|
const { body } = req.body
|
|
|
|
|
const multiAuth = typeof body.multiAuth === 'string' ? body.multiAuth === 'true' : !!body.multiAuth
|
|
|
|
|
|
2023-07-29 19:38:20 +00:00
|
|
|
|
try {
|
2024-09-12 18:05:11 +00:00
|
|
|
|
// does the given challenge (k1) exist in our db?
|
2023-07-29 19:38:20 +00:00
|
|
|
|
const lnauth = await prisma.lnAuth.findUnique({ where: { k1 } })
|
2024-09-12 18:05:11 +00:00
|
|
|
|
|
|
|
|
|
// delete challenge to prevent replay attacks
|
2023-07-29 19:38:20 +00:00
|
|
|
|
await prisma.lnAuth.delete({ where: { k1 } })
|
2024-09-12 18:05:11 +00:00
|
|
|
|
|
|
|
|
|
// does the given pubkey match the one for which we verified the signature?
|
2023-07-29 19:38:20 +00:00
|
|
|
|
if (lnauth.pubkey === pubkey) {
|
2024-09-12 18:05:11 +00:00
|
|
|
|
// does the pubkey already exist in our db?
|
2023-07-29 19:38:20 +00:00
|
|
|
|
let user = await prisma.user.findUnique({ where: { [pubkeyColumnName]: pubkey } })
|
2024-09-12 18:05:11 +00:00
|
|
|
|
|
|
|
|
|
// get token if it exists
|
2023-07-29 19:38:20 +00:00
|
|
|
|
const token = await getToken({ req })
|
|
|
|
|
if (!user) {
|
2024-09-12 18:05:11 +00:00
|
|
|
|
// we have not seen this pubkey before
|
|
|
|
|
|
|
|
|
|
// only update our pubkey if we're not currently trying to add a new account
|
|
|
|
|
if (token?.id && !multiAuth) {
|
2023-07-29 19:38:20 +00:00
|
|
|
|
user = await prisma.user.update({ where: { id: token.id }, data: { [pubkeyColumnName]: pubkey } })
|
|
|
|
|
} else {
|
2024-09-12 18:05:11 +00:00
|
|
|
|
// we're not logged in: create new user with that pubkey
|
2023-07-29 19:38:20 +00:00
|
|
|
|
user = await prisma.user.create({ data: { name: pubkey.slice(0, 10), [pubkeyColumnName]: pubkey } })
|
2021-06-27 03:09:39 +00:00
|
|
|
|
}
|
2024-09-12 18:05:11 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (token && token?.id !== user.id && multiAuth) {
|
|
|
|
|
// we're logged in as a different user than the one we're authenticating as
|
|
|
|
|
// and we want to add a new account. this means we want to add this account
|
|
|
|
|
// to our list of accounts for switching between so we issue a new JWT and
|
|
|
|
|
// update the cookies for multi-authentication.
|
|
|
|
|
const secret = process.env.NEXTAUTH_SECRET
|
|
|
|
|
const userJWT = await encodeJWT({ token: { id: user.id, name: user.name, email: user.email }, secret })
|
|
|
|
|
setMultiAuthCookies(req, res, { ...user, jwt: userJWT })
|
|
|
|
|
return token
|
2021-06-27 03:09:39 +00:00
|
|
|
|
}
|
2023-07-29 19:38:20 +00:00
|
|
|
|
|
|
|
|
|
return user
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.log(error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
2023-08-08 00:50:01 +00:00
|
|
|
|
async function nostrEventAuth (event) {
|
|
|
|
|
// parse event
|
|
|
|
|
const e = JSON.parse(event)
|
|
|
|
|
|
|
|
|
|
// is the event id a hash of this event
|
|
|
|
|
const id = createHash('sha256').update(
|
|
|
|
|
JSON.stringify(
|
|
|
|
|
[0, e.pubkey, e.created_at, e.kind, e.tags, e.content]
|
|
|
|
|
)
|
|
|
|
|
).digest('hex')
|
|
|
|
|
if (id !== e.id) {
|
|
|
|
|
throw new Error('invalid event id')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// is the signature valid
|
|
|
|
|
if (!(await schnorr.verify(e.sig, e.id, e.pubkey))) {
|
|
|
|
|
throw new Error('invalid signature')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// is the challenge present in the event
|
|
|
|
|
if (!(e.tags[0].length === 2 && e.tags[0][0] === 'challenge')) {
|
|
|
|
|
throw new Error('expected tags = [["challenge", <challenge>]]')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const pubkey = e.pubkey
|
|
|
|
|
const k1 = e.tags[0][1]
|
|
|
|
|
await prisma.lnAuth.update({ data: { pubkey }, where: { k1 } })
|
|
|
|
|
|
|
|
|
|
return { k1, pubkey }
|
|
|
|
|
}
|
|
|
|
|
|
2024-02-14 19:33:31 +00:00
|
|
|
|
/** @type {import('next-auth/providers').Provider[]} */
|
2024-09-12 18:05:11 +00:00
|
|
|
|
const getProviders = res => [
|
2023-07-29 19:38:20 +00:00
|
|
|
|
CredentialsProvider({
|
|
|
|
|
id: 'lightning',
|
|
|
|
|
name: 'Lightning',
|
|
|
|
|
credentials: {
|
|
|
|
|
pubkey: { label: 'publickey', type: 'text' },
|
|
|
|
|
k1: { label: 'k1', type: 'text' }
|
|
|
|
|
},
|
2024-09-12 18:05:11 +00:00
|
|
|
|
authorize: async (credentials, req) => {
|
|
|
|
|
return await pubkeyAuth(credentials, new NodeNextRequest(req), new NodeNextResponse(res), 'pubkey')
|
|
|
|
|
}
|
2023-07-29 19:38:20 +00:00
|
|
|
|
}),
|
2023-08-08 00:50:01 +00:00
|
|
|
|
CredentialsProvider({
|
|
|
|
|
id: 'nostr',
|
|
|
|
|
name: 'Nostr',
|
|
|
|
|
credentials: {
|
|
|
|
|
event: { label: 'event', type: 'text' }
|
|
|
|
|
},
|
|
|
|
|
authorize: async ({ event }, req) => {
|
|
|
|
|
const credentials = await nostrEventAuth(event)
|
2024-09-12 18:05:11 +00:00
|
|
|
|
return await pubkeyAuth(credentials, new NodeNextRequest(req), new NodeNextResponse(res), 'nostrAuthPubkey')
|
2023-08-08 00:50:01 +00:00
|
|
|
|
}
|
|
|
|
|
}),
|
2023-07-29 19:38:20 +00:00
|
|
|
|
GitHubProvider({
|
|
|
|
|
clientId: process.env.GITHUB_ID,
|
|
|
|
|
clientSecret: process.env.GITHUB_SECRET,
|
|
|
|
|
authorization: {
|
|
|
|
|
url: 'https://github.com/login/oauth/authorize',
|
|
|
|
|
params: { scope: '' }
|
|
|
|
|
},
|
2024-02-14 19:33:31 +00:00
|
|
|
|
profile (profile) {
|
2023-07-29 19:38:20 +00:00
|
|
|
|
return {
|
2023-08-07 20:05:55 +00:00
|
|
|
|
id: profile.id,
|
2023-07-29 19:38:20 +00:00
|
|
|
|
name: profile.login
|
2021-05-21 19:34:40 +00:00
|
|
|
|
}
|
2023-07-29 19:38:20 +00:00
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
TwitterProvider({
|
|
|
|
|
clientId: process.env.TWITTER_ID,
|
|
|
|
|
clientSecret: process.env.TWITTER_SECRET,
|
2024-02-14 19:33:31 +00:00
|
|
|
|
profile (profile) {
|
2023-07-29 19:38:20 +00:00
|
|
|
|
return {
|
2023-08-07 20:05:55 +00:00
|
|
|
|
id: profile.id,
|
2023-07-29 19:38:20 +00:00
|
|
|
|
name: profile.screen_name
|
2021-05-21 19:34:40 +00:00
|
|
|
|
}
|
2023-07-29 19:38:20 +00:00
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
EmailProvider({
|
|
|
|
|
server: process.env.LOGIN_EMAIL_SERVER,
|
|
|
|
|
from: process.env.LOGIN_EMAIL_FROM,
|
|
|
|
|
sendVerificationRequest
|
|
|
|
|
})
|
|
|
|
|
]
|
|
|
|
|
|
2024-02-14 19:33:31 +00:00
|
|
|
|
/** @returns {import('next-auth').AuthOptions} */
|
2024-09-12 18:05:11 +00:00
|
|
|
|
export const getAuthOptions = (req, res) => ({
|
|
|
|
|
callbacks: getCallbacks(req, res),
|
|
|
|
|
providers: getProviders(res),
|
2024-05-04 23:06:15 +00:00
|
|
|
|
adapter: {
|
|
|
|
|
...PrismaAdapter(prisma),
|
|
|
|
|
createUser: data => {
|
|
|
|
|
// replace email with email hash in new user payload
|
|
|
|
|
if (data.email) {
|
|
|
|
|
const { email } = data
|
|
|
|
|
data.emailHash = hashEmail({ email })
|
|
|
|
|
delete data.email
|
|
|
|
|
// data.email used to be used for name of new accounts. since it's missing, let's generate a new name
|
|
|
|
|
data.name = data.emailHash.substring(0, 10)
|
|
|
|
|
// sign them up for the newsletter
|
|
|
|
|
// don't await it, let it run async
|
|
|
|
|
enrollInNewsletter({ email })
|
|
|
|
|
}
|
|
|
|
|
return prisma.user.create({ data })
|
|
|
|
|
},
|
|
|
|
|
getUserByEmail: async email => {
|
|
|
|
|
const hashedEmail = hashEmail({ email })
|
|
|
|
|
let user = await prisma.user.findUnique({
|
|
|
|
|
where: {
|
|
|
|
|
// lookup by email hash since we don't store plaintext emails any more
|
|
|
|
|
emailHash: hashedEmail
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
if (!user) {
|
|
|
|
|
user = await prisma.user.findUnique({
|
|
|
|
|
where: {
|
|
|
|
|
// lookup by email as a fallback in case a user attempts to login by email during the migration
|
|
|
|
|
// and their email hasn't been migrated yet
|
|
|
|
|
email
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
// HACK! This is required to satisfy next-auth's check here:
|
|
|
|
|
// https://github.com/nextauthjs/next-auth/blob/5b647e1ac040250ad055e331ba97f8fa461b63cc/packages/next-auth/src/core/routes/callback.ts#L227
|
|
|
|
|
// since we are nulling `email`, but it expects it to be truthy there.
|
|
|
|
|
// Since we have the email from the input request, we can copy it here and pretend like we store user emails, even though we don't.
|
|
|
|
|
if (user) {
|
|
|
|
|
user.email = email
|
|
|
|
|
}
|
|
|
|
|
return user
|
|
|
|
|
}
|
|
|
|
|
},
|
2023-07-29 19:38:20 +00:00
|
|
|
|
session: {
|
|
|
|
|
strategy: 'jwt'
|
2021-06-27 03:09:39 +00:00
|
|
|
|
},
|
2021-04-24 21:05:07 +00:00
|
|
|
|
pages: {
|
2023-01-10 23:13:37 +00:00
|
|
|
|
signIn: '/login',
|
2023-07-29 19:38:20 +00:00
|
|
|
|
verifyRequest: '/email',
|
|
|
|
|
error: '/auth/error'
|
2024-02-14 19:33:31 +00:00
|
|
|
|
},
|
|
|
|
|
events: getEventCallbacks()
|
2022-12-19 22:27:52 +00:00
|
|
|
|
})
|
2022-03-10 22:47:00 +00:00
|
|
|
|
|
2024-05-04 23:06:15 +00:00
|
|
|
|
async function enrollInNewsletter ({ email }) {
|
|
|
|
|
if (process.env.LIST_MONK_URL && process.env.LIST_MONK_AUTH) {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(process.env.LIST_MONK_URL + '/api/subscribers', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
Authorization: 'Basic ' + Buffer.from(process.env.LIST_MONK_AUTH).toString('base64')
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
email,
|
|
|
|
|
name: 'blank',
|
|
|
|
|
lists: [2],
|
|
|
|
|
status: 'enabled',
|
|
|
|
|
preconfirm_subscriptions: true
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
const jsonResponse = await response.json()
|
|
|
|
|
console.log(jsonResponse)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.log('error signing user up for newsletter')
|
|
|
|
|
console.log(err)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
console.log('LIST MONK env vars not set, skipping newsletter enrollment')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-07-29 19:38:20 +00:00
|
|
|
|
export default async (req, res) => {
|
2024-09-12 18:05:11 +00:00
|
|
|
|
await NextAuth(req, res, getAuthOptions(req, res))
|
2023-07-29 19:38:20 +00:00
|
|
|
|
}
|
|
|
|
|
|
2023-06-19 22:42:47 +00:00
|
|
|
|
async function sendVerificationRequest ({
|
2022-03-10 22:47:00 +00:00
|
|
|
|
identifier: email,
|
|
|
|
|
url,
|
|
|
|
|
provider
|
|
|
|
|
}) {
|
2024-05-04 23:06:15 +00:00
|
|
|
|
let user = await prisma.user.findUnique({
|
|
|
|
|
where: {
|
|
|
|
|
// Look for the user by hashed email
|
|
|
|
|
emailHash: hashEmail({ email })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
if (!user) {
|
|
|
|
|
user = await prisma.user.findUnique({
|
|
|
|
|
where: {
|
|
|
|
|
// or plaintext email, in case a user tries to login via email during the migration
|
|
|
|
|
// before their particular record has been migrated
|
|
|
|
|
email
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
2023-06-19 22:42:47 +00:00
|
|
|
|
|
2022-03-10 22:47:00 +00:00
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const { server, from } = provider
|
2023-07-29 19:38:20 +00:00
|
|
|
|
|
|
|
|
|
const site = new URL(url).host
|
2022-03-10 22:47:00 +00:00
|
|
|
|
|
|
|
|
|
nodemailer.createTransport(server).sendMail(
|
|
|
|
|
{
|
|
|
|
|
to: email,
|
|
|
|
|
from,
|
|
|
|
|
subject: `login to ${site}`,
|
|
|
|
|
text: text({ url, site, email }),
|
2023-06-19 22:42:47 +00:00
|
|
|
|
html: user ? html({ url, site, email }) : newUserHtml({ url, site, email })
|
2022-03-10 22:47:00 +00:00
|
|
|
|
},
|
|
|
|
|
(error) => {
|
|
|
|
|
if (error) {
|
|
|
|
|
return reject(new Error('SEND_VERIFICATION_EMAIL_ERROR', error))
|
|
|
|
|
}
|
|
|
|
|
return resolve()
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Email HTML body
|
|
|
|
|
const html = ({ url, site, email }) => {
|
|
|
|
|
// Insert invisible space into domains and email address to prevent both the
|
|
|
|
|
// email address and the domain from being turned into a hyperlink by email
|
|
|
|
|
// clients like Outlook and Apple mail, as this is confusing because it seems
|
|
|
|
|
// like they are supposed to click on their email address to sign in.
|
|
|
|
|
const escapedEmail = `${email.replace(/\./g, '​.')}`
|
|
|
|
|
const escapedSite = `${site.replace(/\./g, '​.')}`
|
|
|
|
|
|
|
|
|
|
// Some simple styling options
|
|
|
|
|
const backgroundColor = '#f5f5f5'
|
|
|
|
|
const textColor = '#212529'
|
|
|
|
|
const mainBackgroundColor = '#ffffff'
|
|
|
|
|
const buttonBackgroundColor = '#FADA5E'
|
|
|
|
|
const buttonTextColor = '#212529'
|
|
|
|
|
|
|
|
|
|
// Uses tables for layout and inline CSS due to email client limitations
|
|
|
|
|
return `
|
|
|
|
|
<body style="background: ${backgroundColor};">
|
|
|
|
|
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
|
|
|
|
<tr>
|
|
|
|
|
<td align="center" style="padding: 10px 0px 20px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
|
|
|
|
|
<strong>${escapedSite}</strong>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</table>
|
|
|
|
|
<table width="100%" border="0" cellspacing="20" cellpadding="0" style="background: ${mainBackgroundColor}; max-width: 600px; margin: auto; border-radius: 10px;">
|
|
|
|
|
<tr>
|
|
|
|
|
<td align="center" style="padding: 10px 0px 0px 0px; font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
|
|
|
|
|
login as <strong>${escapedEmail}</strong>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
<tr>
|
|
|
|
|
<td align="center" style="padding: 20px 0;">
|
|
|
|
|
<table border="0" cellspacing="0" cellpadding="0">
|
|
|
|
|
<tr>
|
2023-06-19 22:42:47 +00:00
|
|
|
|
<td align="center" style="border-radius: 5px;" bgcolor="${buttonBackgroundColor}"><a href="${url}" target="_blank" style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${buttonTextColor}; text-decoration: none; text-decoration: none;border-radius: 5px; padding: 10px 20px; border: 1px solid ${buttonBackgroundColor}; display: inline-block; font-weight: bold;">login</a></td>
|
2022-03-10 22:47:00 +00:00
|
|
|
|
</tr>
|
|
|
|
|
</table>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
<tr>
|
|
|
|
|
<td align="center" style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
|
|
|
|
|
Or copy and paste this link: <a href="#" style="text-decoration:none; color:${textColor}">${url}</a>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
<tr>
|
|
|
|
|
<td align="center" style="padding: 0px 0px 10px 0px; font-size: 10px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
|
|
|
|
|
If you did not request this email you can safely ignore it.
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</table>
|
|
|
|
|
</body>
|
|
|
|
|
`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Email text body –fallback for email clients that don't render HTML
|
|
|
|
|
const text = ({ url, site }) => `Sign in to ${site}\n${url}\n\n`
|
2023-06-19 22:42:47 +00:00
|
|
|
|
|
|
|
|
|
const newUserHtml = ({ url, site, email }) => {
|
|
|
|
|
const escapedEmail = `${email.replace(/\./g, '​.')}`
|
|
|
|
|
|
|
|
|
|
const replaceCb = (path) => {
|
|
|
|
|
const urlObj = new URL(url)
|
|
|
|
|
urlObj.searchParams.set('callbackUrl', path)
|
|
|
|
|
return urlObj.href
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const dailyUrl = replaceCb('/daily')
|
|
|
|
|
const guideUrl = replaceCb('/guide')
|
|
|
|
|
const faqUrl = replaceCb('/faq')
|
2023-07-09 17:37:12 +00:00
|
|
|
|
const topUrl = replaceCb('/top/stackers/forever')
|
2023-06-19 22:42:47 +00:00
|
|
|
|
const postUrl = replaceCb('/post')
|
|
|
|
|
|
|
|
|
|
// Some simple styling options
|
|
|
|
|
const backgroundColor = '#f5f5f5'
|
|
|
|
|
const textColor = '#212529'
|
|
|
|
|
const mainBackgroundColor = '#ffffff'
|
|
|
|
|
const buttonBackgroundColor = '#FADA5E'
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
<!doctype html>
|
|
|
|
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
|
|
|
|
|
|
|
|
|
<head>
|
|
|
|
|
<title>
|
|
|
|
|
</title>
|
|
|
|
|
<!--[if !mso]><!-->
|
|
|
|
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
|
|
|
<!--<![endif]-->
|
|
|
|
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
|
|
<style type="text/css">
|
|
|
|
|
#outlook a {
|
|
|
|
|
padding: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
body {
|
|
|
|
|
margin: 0;
|
|
|
|
|
padding: 0;
|
|
|
|
|
-webkit-text-size-adjust: 100%;
|
|
|
|
|
-ms-text-size-adjust: 100%;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
table,
|
|
|
|
|
td {
|
|
|
|
|
border-collapse: collapse;
|
|
|
|
|
mso-table-lspace: 0pt;
|
|
|
|
|
mso-table-rspace: 0pt;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
img {
|
|
|
|
|
border: 0;
|
|
|
|
|
height: auto;
|
|
|
|
|
line-height: 100%;
|
|
|
|
|
outline: none;
|
|
|
|
|
text-decoration: none;
|
|
|
|
|
-ms-interpolation-mode: bicubic;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
p {
|
|
|
|
|
display: block;
|
|
|
|
|
margin: 13px 0;
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
<!--[if mso]>
|
|
|
|
|
<noscript>
|
|
|
|
|
<xml>
|
|
|
|
|
<o:OfficeDocumentSettings>
|
|
|
|
|
<o:AllowPNG/>
|
|
|
|
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
|
|
|
|
</o:OfficeDocumentSettings>
|
|
|
|
|
</xml>
|
|
|
|
|
</noscript>
|
|
|
|
|
<![endif]-->
|
|
|
|
|
<!--[if lte mso 11]>
|
|
|
|
|
<style type="text/css">
|
|
|
|
|
.mj-outlook-group-fix { width:100% !important; }
|
|
|
|
|
</style>
|
|
|
|
|
<![endif]-->
|
|
|
|
|
<style type="text/css">
|
|
|
|
|
@media only screen and (min-width:480px) {
|
|
|
|
|
.mj-column-per-100 {
|
|
|
|
|
width: 100% !important;
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
<style media="screen and (min-width:480px)">
|
|
|
|
|
.moz-text-html .mj-column-per-100 {
|
|
|
|
|
width: 100% !important;
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
<style type="text/css">
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
|
|
|
|
|
<body style="word-spacing:normal;background-color:${backgroundColor};">
|
|
|
|
|
<div style="background-color:${backgroundColor};">
|
|
|
|
|
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
|
|
|
|
<div style="margin:0px auto;max-width:600px;">
|
|
|
|
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
|
|
|
|
<tbody>
|
|
|
|
|
<tr>
|
|
|
|
|
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
|
|
|
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
|
|
|
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
|
|
|
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
|
|
|
|
<tbody>
|
|
|
|
|
<tr>
|
|
|
|
|
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
|
|
|
|
<div style="font-family:Helvetica, Arial, sans-serif;font-size:22px;line-height:1;text-align:center;color:#000000;"><b>Welcome to Stacker News!</b></div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="${mainBackgroundColor}" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
|
|
|
|
<div style="background:${mainBackgroundColor};background-color:${mainBackgroundColor};margin:0px auto;max-width:600px;">
|
|
|
|
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:${mainBackgroundColor};background-color:${mainBackgroundColor};width:100%;">
|
|
|
|
|
<tbody>
|
|
|
|
|
<tr>
|
|
|
|
|
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
|
|
|
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
|
|
|
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
|
|
|
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
|
|
|
|
<tbody>
|
|
|
|
|
<tr>
|
|
|
|
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
|
|
|
|
<div style="font-family:Helvetica, Arial, sans-serif;font-size:16px;line-height:22px;text-align:left;color:#000000;">If you know how Stacker News works, click the login button below.</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
<tr>
|
|
|
|
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
|
|
|
|
<div style="font-family:Helvetica, Arial, sans-serif;font-size:16px;line-height:22px;text-align:left;color:#000000;">If you want to learn how Stacker News works, keep reading.</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="${backgroundColor}" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
|
|
|
|
<div style="background:${backgroundColor};background-color:${backgroundColor};margin:0px auto;max-width:600px;">
|
|
|
|
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:${backgroundColor};background-color:${backgroundColor};width:100%;">
|
|
|
|
|
<tbody>
|
|
|
|
|
<tr>
|
|
|
|
|
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
|
|
|
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
|
|
|
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
|
|
|
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
|
|
|
|
<tbody>
|
|
|
|
|
<tr>
|
|
|
|
|
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
|
|
|
|
<div style="font-family:Helvetica, Arial, sans-serif;font-size:18px;line-height:1;text-align:center;color:#000000;">login as <b>${escapedEmail}</b></div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
<tr>
|
|
|
|
|
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;padding-top:20px;padding-bottom:30px;word-break:break-word;">
|
|
|
|
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
|
|
|
|
|
<tr>
|
|
|
|
|
<td align="center" bgcolor="${buttonBackgroundColor}" role="presentation" style="border:none;border-radius:5px;cursor:auto;mso-padding-alt:15px 40px;background:${buttonBackgroundColor};" valign="middle">
|
|
|
|
|
<a href="${url}" style="display:inline-block;background:${buttonBackgroundColor};color:${textColor};font-family:Helvetica, Arial, sans-serif;font-size:22px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:15px 40px;mso-padding-alt:0px;border-radius:5px;" target="_blank">
|
|
|
|
|
<mj-text align="center" font-family="Helvetica, Arial, sans-serif" font-size="20px"><b font-family="Helvetica, Arial, sans-serif">login</b></mj-text>
|
|
|
|
|
</a>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</table>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
<tr>
|
|
|
|
|
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
|
|
|
|
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:24px;text-align:center;color:#000000;">Or copy and paste this link: <a href="#" style="text-decoration:none; color:#787878">${url}</a></div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="${mainBackgroundColor}" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
|
|
|
|
<div style="background:${mainBackgroundColor};background-color:${mainBackgroundColor};margin:0px auto;max-width:600px;">
|
|
|
|
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:${mainBackgroundColor};background-color:${mainBackgroundColor};width:100%;">
|
|
|
|
|
<tbody>
|
|
|
|
|
<tr>
|
|
|
|
|
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
|
|
|
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
|
|
|
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
|
|
|
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
|
|
|
|
<tbody>
|
|
|
|
|
<tr>
|
|
|
|
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
|
|
|
|
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">Stacker News is like Reddit or Hacker News, but it <b>pays you Bitcoin</b>. Instead of giving posts or comments “upvotes,” Stacker News users (aka stackers) send you small amounts of Bitcoin called sats.</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
<tr>
|
|
|
|
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
|
|
|
|
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">In fact, <a href="${topUrl}"><b>some stackers</b></a> have already stacked <b>millions of sats</b> just for posting and starting thoughtful conversations.</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
<tr>
|
|
|
|
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
|
|
|
|
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">To start earning sats, <a href="${postUrl}"><b><i>click here to make your first post</i></b></a>. You can share links, discussion questions, polls, or even bounties with other stackers. <a href="${guideUrl}">This guide</a> offers some useful tips and best practices for sharing content on Stacker News.</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
<tr>
|
|
|
|
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
|
|
|
|
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">If you’re not sure what to share, <a href="${dailyUrl}"><b><i>click here to introduce yourself to the community</i></b></a> with a comment on the daily discussion thread.</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
<tr>
|
|
|
|
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
|
|
|
|
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">If you still have questions, <a href="${faqUrl}"><b><i>click here to learn more about Stacker News</i></b></a> by reading our FAQ.</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
<tr>
|
|
|
|
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
|
|
|
|
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">If anything isn’t clear, comment on the FAQ post and we’ll answer your question.</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
<tr>
|
|
|
|
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
|
|
|
|
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">Zap,<br /> Stacker News</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="${mainBackgroundColor}" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
|
|
|
|
<div style="background:${mainBackgroundColor};background-color:${mainBackgroundColor};margin:0px auto;max-width:600px;">
|
|
|
|
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:${mainBackgroundColor};background-color:${mainBackgroundColor};width:100%;">
|
|
|
|
|
<tbody>
|
|
|
|
|
<tr>
|
|
|
|
|
<td style="direction:ltr;font-size:0px;padding:0px 0px 20px 0px;text-align:center;">
|
|
|
|
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
|
|
|
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
|
|
|
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
|
|
|
|
<tbody>
|
|
|
|
|
<tr>
|
|
|
|
|
<td align="center" style="font-size:0px;padding:0px 25px 0px 25px;word-break:break-word;">
|
|
|
|
|
<div style="font-family:Arial, sans-serif;font-size:14px;line-height:28px;text-align:center;color:#55575d;">P.S. Stacker News loves you!</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
|
|
|
|
<div style="margin:0px auto;max-width:600px;">
|
|
|
|
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
|
|
|
|
<tbody>
|
|
|
|
|
<tr>
|
|
|
|
|
<td style="direction:ltr;font-size:0px;padding:20px 0px 20px 0px;text-align:center;">
|
|
|
|
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
|
|
|
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
|
|
|
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
|
|
|
|
<tbody>
|
|
|
|
|
<tr>
|
|
|
|
|
<td align="center" style="font-size:0px;padding:0px 20px;word-break:break-word;">
|
|
|
|
|
<div style="font-family:Arial, sans-serif;font-size:11px;line-height:22px;text-align:center;color:#55575d;">If you did not request this email you can safely ignore it.</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
|
|
|
|
</div>
|
|
|
|
|
</body>
|
|
|
|
|
|
|
|
|
|
</html>
|
|
|
|
|
`
|
|
|
|
|
}
|