feat: add authjs backend support
This commit is contained in:
parent
d5d04468cb
commit
06d6ecd2a3
@ -42,8 +42,8 @@ app.use(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
app.use('/auth/*', ExpressAuth(authConfig));
|
|
||||||
app.use('/health', healthRouter);
|
app.use('/health', healthRouter);
|
||||||
|
app.use('/api/auth/*', ExpressAuth(authConfig));
|
||||||
app.use('/api/website', websiteRouter);
|
app.use('/api/website', websiteRouter);
|
||||||
app.use('/api/workspace', workspaceRouter);
|
app.use('/api/workspace', workspaceRouter);
|
||||||
app.use('/monitor', monitorRouter);
|
app.use('/monitor', monitorRouter);
|
||||||
|
@ -1,11 +1,246 @@
|
|||||||
import { AuthConfig } from '@auth/core';
|
import { AuthConfig } from '@auth/core';
|
||||||
import Nodemailer from '@auth/core/providers/nodemailer';
|
import Nodemailer from '@auth/core/providers/nodemailer';
|
||||||
|
import Credentials from '@auth/core/providers/credentials';
|
||||||
import { env } from '../utils/env.js';
|
import { env } from '../utils/env.js';
|
||||||
import { PrismaAdapter } from '@auth/prisma-adapter';
|
|
||||||
import { prisma } from './_client.js';
|
import { prisma } from './_client.js';
|
||||||
|
import type { PrismaClient, Prisma, User } from '@prisma/client';
|
||||||
|
import type {
|
||||||
|
Adapter,
|
||||||
|
AdapterAccount,
|
||||||
|
AdapterSession,
|
||||||
|
AdapterUser,
|
||||||
|
} from '@auth/core/adapters';
|
||||||
|
import { authUser, createUserWithAuthjs } from './user.js';
|
||||||
|
import { createTransport } from 'nodemailer';
|
||||||
|
import { Theme } from '@auth/core/types';
|
||||||
|
import { generateSMTPHTML } from '../utils/smtp.js';
|
||||||
|
|
||||||
export const authConfig: Omit<AuthConfig, 'raw'> = {
|
export const authConfig: Omit<AuthConfig, 'raw'> = {
|
||||||
providers: [Nodemailer(env.auth.email)],
|
debug: true,
|
||||||
adapter: PrismaAdapter(prisma),
|
providers: [
|
||||||
|
Credentials({
|
||||||
|
name: 'Account',
|
||||||
|
credentials: {
|
||||||
|
username: { label: 'Username' },
|
||||||
|
password: { label: 'Password', type: 'password' },
|
||||||
|
},
|
||||||
|
authorize: async (credentials) => {
|
||||||
|
const user = await authUser(
|
||||||
|
String(credentials.username),
|
||||||
|
String(credentials.password)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// No user found, so this is their first attempt to login
|
||||||
|
// meaning this is also the place you could do registration
|
||||||
|
throw new Error('User not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// return user object with the their profile data
|
||||||
|
return toAdapterUser(user);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Nodemailer({
|
||||||
|
name: 'Email',
|
||||||
|
...env.auth.email,
|
||||||
|
async sendVerificationRequest(params) {
|
||||||
|
const { identifier, url, provider, theme } = params;
|
||||||
|
const { host } = new URL(url);
|
||||||
|
const transport = createTransport(provider.server);
|
||||||
|
const result = await transport.sendMail({
|
||||||
|
to: identifier,
|
||||||
|
from: provider.from,
|
||||||
|
subject: `Sign in Tianji to ${host}`,
|
||||||
|
text: `Sign in Tianji to ${host}\n${url}\n\n`,
|
||||||
|
html: nodemailHtmlBody({ url, host, theme }),
|
||||||
|
});
|
||||||
|
const failed = result.rejected.concat(result.pending).filter(Boolean);
|
||||||
|
if (failed.length) {
|
||||||
|
throw new Error(`Email (${failed.join(', ')}) could not be sent`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
adapter: TianjiPrismaAdapter(prisma),
|
||||||
secret: env.auth.secret,
|
secret: env.auth.secret,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function toAdapterUser(
|
||||||
|
user: Pick<
|
||||||
|
User,
|
||||||
|
'id' | 'nickname' | 'username' | 'avatar' | 'email' | 'emailVerified'
|
||||||
|
>
|
||||||
|
): AdapterUser {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.nickname ?? user.username,
|
||||||
|
image: user.avatar,
|
||||||
|
email: user.email!,
|
||||||
|
emailVerified: user.emailVerified,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function TianjiPrismaAdapter(
|
||||||
|
prisma: PrismaClient | ReturnType<PrismaClient['$extends']>
|
||||||
|
): Adapter {
|
||||||
|
const p = prisma as PrismaClient;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// We need to let Prisma generate the ID because our default UUID is incompatible with MongoDB
|
||||||
|
createUser: async ({ id: _id, ...data }) => {
|
||||||
|
const user = await createUserWithAuthjs(data);
|
||||||
|
|
||||||
|
return toAdapterUser(user);
|
||||||
|
},
|
||||||
|
getUser: async (id) => {
|
||||||
|
const user = await p.user.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return toAdapterUser(user);
|
||||||
|
},
|
||||||
|
getUserByEmail: async (email) => {
|
||||||
|
const user = await p.user.findUnique({ where: { email } });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return toAdapterUser(user);
|
||||||
|
},
|
||||||
|
async getUserByAccount(provider_providerAccountId) {
|
||||||
|
const account = await p.account.findUnique({
|
||||||
|
where: { provider_providerAccountId },
|
||||||
|
select: { user: true },
|
||||||
|
});
|
||||||
|
return (account?.user as AdapterUser) ?? null;
|
||||||
|
},
|
||||||
|
updateUser: ({ id, ...data }) =>
|
||||||
|
p.user.update({ where: { id }, data }) as Promise<AdapterUser>,
|
||||||
|
deleteUser: (id) =>
|
||||||
|
p.user.delete({ where: { id } }) as Promise<AdapterUser>,
|
||||||
|
linkAccount: (data) =>
|
||||||
|
p.account.create({ data }) as unknown as AdapterAccount,
|
||||||
|
unlinkAccount: (provider_providerAccountId) =>
|
||||||
|
p.account.delete({
|
||||||
|
where: { provider_providerAccountId },
|
||||||
|
}) as unknown as AdapterAccount,
|
||||||
|
async getSessionAndUser(sessionToken) {
|
||||||
|
const userAndSession = await p.session.findUnique({
|
||||||
|
where: { sessionToken },
|
||||||
|
include: { user: true },
|
||||||
|
});
|
||||||
|
if (!userAndSession) return null;
|
||||||
|
const { user, ...session } = userAndSession;
|
||||||
|
return { user, session } as {
|
||||||
|
user: AdapterUser;
|
||||||
|
session: AdapterSession;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
createSession: (data) => p.session.create({ data }),
|
||||||
|
updateSession: (data) =>
|
||||||
|
p.session.update({ where: { sessionToken: data.sessionToken }, data }),
|
||||||
|
deleteSession: (sessionToken) =>
|
||||||
|
p.session.delete({ where: { sessionToken } }),
|
||||||
|
async createVerificationToken(data) {
|
||||||
|
const verificationToken = await p.verificationToken.create({ data });
|
||||||
|
// @ts-expect-errors // MongoDB needs an ID, but we don't
|
||||||
|
if (verificationToken.id) delete verificationToken.id;
|
||||||
|
return verificationToken;
|
||||||
|
},
|
||||||
|
async useVerificationToken(identifier_token) {
|
||||||
|
try {
|
||||||
|
const verificationToken = await p.verificationToken.delete({
|
||||||
|
where: { identifier_token },
|
||||||
|
});
|
||||||
|
// @ts-expect-errors // MongoDB needs an ID, but we don't
|
||||||
|
if (verificationToken.id) delete verificationToken.id;
|
||||||
|
return verificationToken;
|
||||||
|
} catch (error) {
|
||||||
|
// If token already used/deleted, just return null
|
||||||
|
// https://www.prisma.io/docs/reference/api-reference/error-reference#p2025
|
||||||
|
if ((error as Prisma.PrismaClientKnownRequestError).code === 'P2025')
|
||||||
|
return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getAccount(providerAccountId, provider) {
|
||||||
|
return p.account.findFirst({
|
||||||
|
where: { providerAccountId, provider },
|
||||||
|
}) as Promise<AdapterAccount | null>;
|
||||||
|
},
|
||||||
|
// async createAuthenticator(authenticator) {
|
||||||
|
// return p.authenticator.create({
|
||||||
|
// data: authenticator,
|
||||||
|
// });
|
||||||
|
// },
|
||||||
|
// async getAuthenticator(credentialID) {
|
||||||
|
// return p.authenticator.findUnique({
|
||||||
|
// where: { credentialID },
|
||||||
|
// });
|
||||||
|
// },
|
||||||
|
// async listAuthenticatorsByUserId(userId) {
|
||||||
|
// return p.authenticator.findMany({
|
||||||
|
// where: { userId },
|
||||||
|
// });
|
||||||
|
// },
|
||||||
|
// async updateAuthenticatorCounter(credentialID, counter) {
|
||||||
|
// return p.authenticator.update({
|
||||||
|
// where: { credentialID },
|
||||||
|
// data: { counter },
|
||||||
|
// });
|
||||||
|
// },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodemailHtmlBody(params: { url: string; host: string; theme: Theme }) {
|
||||||
|
const { url, host, theme } = params;
|
||||||
|
|
||||||
|
const escapedHost = host.replace(/\./g, '​.');
|
||||||
|
|
||||||
|
const brandColor = theme.brandColor || '#346df1';
|
||||||
|
const color = {
|
||||||
|
background: '#f9f9f9',
|
||||||
|
text: '#444',
|
||||||
|
mainBackground: '#fff',
|
||||||
|
buttonBackground: brandColor,
|
||||||
|
buttonBorder: brandColor,
|
||||||
|
buttonText: theme.buttonText || '#fff',
|
||||||
|
};
|
||||||
|
|
||||||
|
const body = `
|
||||||
|
<body style="background: ${color.background};">
|
||||||
|
<table width="100%" border="0" cellspacing="20" cellpadding="0"
|
||||||
|
style="background: ${color.mainBackground}; max-width: 600px; margin: auto; border-radius: 10px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center"
|
||||||
|
style="padding: 10px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
|
||||||
|
Sign in Tianji to <strong>${escapedHost}</strong>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 20px 0;">
|
||||||
|
<table border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="border-radius: 5px;" bgcolor="${color.buttonBackground}"><a href="${url}"
|
||||||
|
target="_blank"
|
||||||
|
style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${color.buttonText}; text-decoration: none; border-radius: 5px; padding: 10px 20px; border: 1px solid ${color.buttonBorder}; display: inline-block; font-weight: bold;">Sign
|
||||||
|
in</a></td>
|
||||||
|
</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: ${color.text};">
|
||||||
|
If you did not request this email you can safely ignore it.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return generateSMTPHTML(body);
|
||||||
|
}
|
||||||
|
@ -2,6 +2,7 @@ import { NotificationProvider } from './type.js';
|
|||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
import SMTPTransport from 'nodemailer/lib/smtp-transport';
|
import SMTPTransport from 'nodemailer/lib/smtp-transport';
|
||||||
import { htmlContentTokenizer } from '../token/index.js';
|
import { htmlContentTokenizer } from '../token/index.js';
|
||||||
|
import { generateSMTPHTML } from '../../../utils/smtp.js';
|
||||||
|
|
||||||
interface SMTPPayload {
|
interface SMTPPayload {
|
||||||
hostname: string;
|
hostname: string;
|
||||||
@ -49,38 +50,3 @@ export const smtp: NotificationProvider = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function generateSMTPHTML(message: string) {
|
|
||||||
return `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Tianji</title>
|
|
||||||
<style>
|
|
||||||
img {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body style="background-color: #fafafa;">
|
|
||||||
<div style="width: 640px; margin: auto;">
|
|
||||||
<header style="margin-bottom: 10px; padding-top: 10px; text-align: center;">
|
|
||||||
<img src="https://tianji.msgbyte.com/img/logo@128.png" width="50" height="50" />
|
|
||||||
</header>
|
|
||||||
<div style="background-color: #fff; border: 1px solid #dddddd; padding: 36px; margin-bottom: 10px;">
|
|
||||||
${message}
|
|
||||||
</div>
|
|
||||||
<footer style="text-align: center;">
|
|
||||||
<div>
|
|
||||||
Sent with ❤ by Tianji.
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<a href="https://github.com/msgbyte/tianji" target="_blank">Github</a>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
@ -4,6 +4,8 @@ import { ROLES, SYSTEM_ROLES } from '@tianji/shared';
|
|||||||
import { jwtVerify } from '../middleware/auth.js';
|
import { jwtVerify } from '../middleware/auth.js';
|
||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { AdapterUser } from '@auth/core/adapters';
|
||||||
|
import { md5 } from '../utils/common.js';
|
||||||
|
|
||||||
async function hashPassword(password: string) {
|
async function hashPassword(password: string) {
|
||||||
return await bcryptjs.hash(password, 10);
|
return await bcryptjs.hash(password, 10);
|
||||||
@ -22,6 +24,10 @@ export async function getUserCount(): Promise<number> {
|
|||||||
const createUserSelect = {
|
const createUserSelect = {
|
||||||
id: true,
|
id: true,
|
||||||
username: true,
|
username: true,
|
||||||
|
nickname: true,
|
||||||
|
avatar: true,
|
||||||
|
email: true,
|
||||||
|
emailVerified: true,
|
||||||
role: true,
|
role: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
@ -132,6 +138,52 @@ export async function createUser(username: string, password: string) {
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createUserWithAuthjs(data: Omit<AdapterUser, 'id'>) {
|
||||||
|
const existCount = await prisma.user.count({
|
||||||
|
where: {
|
||||||
|
email: data.email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existCount > 0) {
|
||||||
|
throw new Error('User already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.$transaction(async (p) => {
|
||||||
|
const newWorkspace = await p.workspace.create({
|
||||||
|
data: {
|
||||||
|
name: data.name ?? data.email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = await p.user.create({
|
||||||
|
data: {
|
||||||
|
username: data.email,
|
||||||
|
nickname: data.name,
|
||||||
|
password: await hashPassword(md5(String(Date.now()))),
|
||||||
|
email: data.email,
|
||||||
|
emailVerified: data.emailVerified,
|
||||||
|
role: SYSTEM_ROLES.user,
|
||||||
|
avatar: data.image,
|
||||||
|
workspaces: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
role: ROLES.owner,
|
||||||
|
workspaceId: newWorkspace.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
currentWorkspaceId: newWorkspace.id,
|
||||||
|
},
|
||||||
|
select: createUserSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return user;
|
||||||
|
});
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
export async function authUser(username: string, password: string) {
|
export async function authUser(username: string, password: string) {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: {
|
where: {
|
||||||
|
@ -25,7 +25,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/core": "^0.34.1",
|
"@auth/core": "^0.34.1",
|
||||||
"@auth/express": "^0.5.5",
|
"@auth/express": "^0.5.5",
|
||||||
"@auth/prisma-adapter": "^2.1.0",
|
|
||||||
"@paralleldrive/cuid2": "^2.2.2",
|
"@paralleldrive/cuid2": "^2.2.2",
|
||||||
"@prisma/client": "5.14.0",
|
"@prisma/client": "5.14.0",
|
||||||
"@tianji/shared": "workspace:^",
|
"@tianji/shared": "workspace:^",
|
||||||
|
@ -1,8 +1,14 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[email]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
|
||||||
|
*/
|
||||||
-- AlterTable
|
-- AlterTable
|
||||||
ALTER TABLE "User" ADD COLUMN "email" TEXT,
|
ALTER TABLE "User" ADD COLUMN "avatar" TEXT,
|
||||||
|
ADD COLUMN "email" TEXT,
|
||||||
ADD COLUMN "emailVerified" TIMESTAMP(3),
|
ADD COLUMN "emailVerified" TIMESTAMP(3),
|
||||||
ADD COLUMN "image" TEXT,
|
ADD COLUMN "nickname" VARCHAR(255);
|
||||||
ADD COLUMN "name" TEXT;
|
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "Account" (
|
CREATE TABLE "Account" (
|
||||||
@ -44,6 +50,9 @@ CREATE TABLE "VerificationToken" (
|
|||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
|
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
@ -19,12 +19,12 @@ generator zod {
|
|||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @unique @default(cuid()) @db.VarChar(30)
|
id String @id @unique @default(cuid()) @db.VarChar(30)
|
||||||
name String?
|
|
||||||
email String?
|
|
||||||
emailVerified DateTime?
|
|
||||||
image String?
|
|
||||||
username String @unique @db.VarChar(255)
|
username String @unique @db.VarChar(255)
|
||||||
password String @db.VarChar(60)
|
password String @db.VarChar(60)
|
||||||
|
email String? @unique
|
||||||
|
emailVerified DateTime?
|
||||||
|
nickname String? @db.VarChar(255)
|
||||||
|
avatar String?
|
||||||
role String @db.VarChar(50)
|
role String @db.VarChar(50)
|
||||||
createdAt DateTime @default(now()) @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @db.Timestamptz(6)
|
||||||
updatedAt DateTime @updatedAt @db.Timestamptz(6)
|
updatedAt DateTime @updatedAt @db.Timestamptz(6)
|
||||||
|
@ -4,12 +4,12 @@ import { CompleteWorkspace, RelatedWorkspaceModelSchema, CompleteAccount, Relate
|
|||||||
|
|
||||||
export const UserModelSchema = z.object({
|
export const UserModelSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string().nullish(),
|
|
||||||
email: z.string().nullish(),
|
|
||||||
emailVerified: z.date().nullish(),
|
|
||||||
image: z.string().nullish(),
|
|
||||||
username: z.string(),
|
username: z.string(),
|
||||||
password: z.string(),
|
password: z.string(),
|
||||||
|
email: z.string().nullish(),
|
||||||
|
emailVerified: z.date().nullish(),
|
||||||
|
nickname: z.string().nullish(),
|
||||||
|
avatar: z.string().nullish(),
|
||||||
role: z.string(),
|
role: z.string(),
|
||||||
createdAt: z.date(),
|
createdAt: z.date(),
|
||||||
updatedAt: z.date(),
|
updatedAt: z.date(),
|
||||||
|
34
src/server/utils/smtp.ts
Normal file
34
src/server/utils/smtp.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
export function generateSMTPHTML(body: string) {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Tianji</title>
|
||||||
|
<style>
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="background-color: #fafafa;">
|
||||||
|
<div style="width: 640px; margin: auto;">
|
||||||
|
<header style="margin-bottom: 10px; padding-top: 10px; text-align: center;">
|
||||||
|
<img src="https://tianji.msgbyte.com/img/logo@128.png" width="50" height="50" />
|
||||||
|
</header>
|
||||||
|
<div style="background-color: #fff; border: 1px solid #dddddd; padding: 36px; margin-bottom: 10px;">
|
||||||
|
${body}
|
||||||
|
</div>
|
||||||
|
<footer style="text-align: center;">
|
||||||
|
<div>
|
||||||
|
Sent with ❤ by Tianji.
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="https://github.com/msgbyte/tianji" target="_blank">Github</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user