From 32ad43ae27a29b946bfba0040cac7eb158c84553 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Fri, 11 Nov 2022 15:12:16 +0100 Subject: [PATCH] feat: add email recepients functionality --- .env.example | 7 ++++ backend/.env.example | 7 ++++ backend/package-lock.json | 33 +++++++++++++++ backend/package.json | 2 + backend/prisma/schema.prisma | 17 ++++++-- backend/src/app.module.ts | 2 + backend/src/email/email.module.ts | 8 ++++ backend/src/email/email.service.ts | 35 ++++++++++++++++ backend/src/share/dto/createShare.dto.ts | 11 ++++- backend/src/share/dto/myShare.dto.ts | 3 ++ backend/src/share/share.module.ts | 3 +- backend/src/share/share.service.ts | 41 ++++++++++++++++--- .../upload/modals/showCreateUploadModal.tsx | 34 +++++++++++++-- frontend/src/pages/upload.tsx | 3 +- frontend/src/services/share.service.ts | 4 +- 15 files changed, 192 insertions(+), 18 deletions(-) create mode 100644 backend/src/email/email.module.ts create mode 100644 backend/src/email/email.service.ts diff --git a/.env.example b/.env.example index 32246ed..b248d12 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,10 @@ MAX_FILE_SIZE=1000000000 # SECURITY JWT_SECRET=long-random-string + +# EMAIL +EMAIL_RECIPIENTS_ENABLED=false +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_EMAIL=pingvin-share@example.com +SMTP_PASSWORD=example \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example index 4b2229f..690bed6 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -6,3 +6,10 @@ ALLOW_UNAUTHENTICATED_SHARES=false # SECURITY JWT_SECRET=random-string + +# Email configuration +EMAIL_RECIPIENTS_ENABLED=false +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_EMAIL=pingvin-share@example.com +SMTP_PASSWORD=example \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 7198d0e..c99b9c1 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -25,6 +25,7 @@ "mime-types": "^2.1.35", "moment": "^2.29.4", "multer": "^1.4.5-lts.1", + "nodemailer": "^6.8.0", "passport": "^0.6.0", "passport-jwt": "^4.0.0", "passport-local": "^1.0.0", @@ -43,6 +44,7 @@ "@types/mime-types": "^2.1.1", "@types/multer": "^1.4.7", "@types/node": "^18.7.23", + "@types/nodemailer": "^6.4.6", "@types/passport-jwt": "^3.0.7", "@types/supertest": "^2.0.12", "@typescript-eslint/eslint-plugin": "^5.40.0", @@ -1214,6 +1216,15 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.23.tgz", "integrity": "sha512-DWNcCHolDq0ZKGizjx2DZjR/PqsYwAcYUJmfMWqtVU2MBMG5Mo+xFZrhGId5r/O5HOuMPyQEcM6KUBp5lBZZBg==" }, + "node_modules/@types/nodemailer": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.6.tgz", + "integrity": "sha512-pD6fL5GQtUKvD2WnPmg5bC2e8kWCAPDwMPmHe/ohQbW+Dy0EcHgZ2oCSuPlWNqk74LS5BVMig1SymQbFMPPK3w==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -5028,6 +5039,14 @@ "integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==", "dev": true }, + "node_modules/nodemailer": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.8.0.tgz", + "integrity": "sha512-EjYvSmHzekz6VNkNd12aUqAco+bOkRe3Of5jVhltqKhEsjw/y0PYPJfp83+s9Wzh1dspYAkUW/YNQ350NATbSQ==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", @@ -8303,6 +8322,15 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.23.tgz", "integrity": "sha512-DWNcCHolDq0ZKGizjx2DZjR/PqsYwAcYUJmfMWqtVU2MBMG5Mo+xFZrhGId5r/O5HOuMPyQEcM6KUBp5lBZZBg==" }, + "@types/nodemailer": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.6.tgz", + "integrity": "sha512-pD6fL5GQtUKvD2WnPmg5bC2e8kWCAPDwMPmHe/ohQbW+Dy0EcHgZ2oCSuPlWNqk74LS5BVMig1SymQbFMPPK3w==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -11224,6 +11252,11 @@ "integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==", "dev": true }, + "nodemailer": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.8.0.tgz", + "integrity": "sha512-EjYvSmHzekz6VNkNd12aUqAco+bOkRe3Of5jVhltqKhEsjw/y0PYPJfp83+s9Wzh1dspYAkUW/YNQ350NATbSQ==" + }, "nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index d3b41e6..9dd0a4b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -27,6 +27,7 @@ "mime-types": "^2.1.35", "moment": "^2.29.4", "multer": "^1.4.5-lts.1", + "nodemailer": "^6.8.0", "passport": "^0.6.0", "passport-jwt": "^4.0.0", "passport-local": "^1.0.0", @@ -45,6 +46,7 @@ "@types/mime-types": "^2.1.1", "@types/multer": "^1.4.7", "@types/node": "^18.7.23", + "@types/nodemailer": "^6.4.6", "@types/passport-jwt": "^3.0.7", "@types/supertest": "^2.0.12", "@typescript-eslint/eslint-plugin": "^5.40.0", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 3677059..0512c99 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -40,10 +40,19 @@ model Share { views Int @default(0) expiration DateTime - creatorId String? - creator User? @relation(fields: [creatorId], references: [id]) - security ShareSecurity? - files File[] + creatorId String? + creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade) + security ShareSecurity? + recipients ShareRecipient[] + files File[] +} + +model ShareRecipient { + id String @id @default(uuid()) + email String + + shareId String + share Share @relation(fields: [shareId], references: [id], onDelete: Cascade) } model File { diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 5ff2934..d93252f 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -13,12 +13,14 @@ import { PrismaService } from "./prisma/prisma.service"; import { ShareController } from "./share/share.controller"; import { ShareModule } from "./share/share.module"; import { UserController } from "./user/user.controller"; +import { EmailModule } from "./email/email.module"; @Module({ imports: [ AuthModule, ShareModule, FileModule, + EmailModule, PrismaModule, ConfigModule.forRoot({ isGlobal: true }), ThrottlerModule.forRoot({ diff --git a/backend/src/email/email.module.ts b/backend/src/email/email.module.ts new file mode 100644 index 0000000..244dfab --- /dev/null +++ b/backend/src/email/email.module.ts @@ -0,0 +1,8 @@ +import { Module } from "@nestjs/common"; +import { EmailService } from "./email.service"; + +@Module({ + providers: [EmailService], + exports: [EmailService], +}) +export class EmailModule {} diff --git a/backend/src/email/email.service.ts b/backend/src/email/email.service.ts new file mode 100644 index 0000000..cfbeab2 --- /dev/null +++ b/backend/src/email/email.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { User } from "@prisma/client"; +import * as nodemailer from "nodemailer"; + +@Injectable() +export class EmailService { + constructor(private config: ConfigService) {} + + // create reusable transporter object using the default SMTP transport + transporter = nodemailer.createTransport({ + host: this.config.get("SMTP_HOST"), + port: parseInt(this.config.get("SMTP_PORT")), + secure: parseInt(this.config.get("SMTP_PORT")) == 465, + auth: { + user: this.config.get("SMTP_EMAIL"), + pass: this.config.get("SMTP_PASSWORD"), + }, + }); + + async sendMail(recipientEmail: string, shareId: string, creator: User) { + const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`; + const creatorIdentifier = + creator.firstName && creator.lastName + ? `${creator.firstName} ${creator.lastName}` + : creator.email; + + await this.transporter.sendMail({ + from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`, + to: recipientEmail, + subject: "Files shared with you", + text: `Hey!\n${creatorIdentifier} shared some files with you. View or dowload the files with this link: ${shareUrl}.\n Shared securely with Pingvin Share 🐧`, + }); + } +} diff --git a/backend/src/share/dto/createShare.dto.ts b/backend/src/share/dto/createShare.dto.ts index a6ad3fd..644b116 100644 --- a/backend/src/share/dto/createShare.dto.ts +++ b/backend/src/share/dto/createShare.dto.ts @@ -1,5 +1,11 @@ import { Type } from "class-transformer"; -import { IsString, Length, Matches, ValidateNested } from "class-validator"; +import { + IsEmail, + IsString, + Length, + Matches, + ValidateNested, +} from "class-validator"; import { ShareSecurityDTO } from "./shareSecurity.dto"; export class CreateShareDTO { @@ -13,6 +19,9 @@ export class CreateShareDTO { @IsString() expiration: string; + @IsEmail({}, { each: true }) + recipients: string[]; + @ValidateNested() @Type(() => ShareSecurityDTO) security: ShareSecurityDTO; diff --git a/backend/src/share/dto/myShare.dto.ts b/backend/src/share/dto/myShare.dto.ts index d484909..ef29c22 100644 --- a/backend/src/share/dto/myShare.dto.ts +++ b/backend/src/share/dto/myShare.dto.ts @@ -8,6 +8,9 @@ export class MyShareDTO extends ShareDTO { @Expose() createdAt: Date; + @Expose() + recipients: string[]; + from(partial: Partial) { return plainToClass(MyShareDTO, partial, { excludeExtraneousValues: true }); } diff --git a/backend/src/share/share.module.ts b/backend/src/share/share.module.ts index f7a5fef..8ca5304 100644 --- a/backend/src/share/share.module.ts +++ b/backend/src/share/share.module.ts @@ -1,11 +1,12 @@ import { forwardRef, Module } from "@nestjs/common"; import { JwtModule } from "@nestjs/jwt"; +import { EmailModule } from "src/email/email.module"; import { FileModule } from "src/file/file.module"; import { ShareController } from "./share.controller"; import { ShareService } from "./share.service"; @Module({ - imports: [JwtModule.register({}), forwardRef(() => FileModule)], + imports: [JwtModule.register({}), EmailModule, forwardRef(() => FileModule)], controllers: [ShareController], providers: [ShareService], exports: [ShareService], diff --git a/backend/src/share/share.service.ts b/backend/src/share/share.service.ts index 9a13630..572e7ba 100644 --- a/backend/src/share/share.service.ts +++ b/backend/src/share/share.service.ts @@ -11,6 +11,7 @@ import * as archiver from "archiver"; import * as argon from "argon2"; import * as fs from "fs"; import * as moment from "moment"; +import { EmailService } from "src/email/email.service"; import { FileService } from "src/file/file.service"; import { PrismaService } from "src/prisma/prisma.service"; import { CreateShareDTO } from "./dto/createShare.dto"; @@ -20,6 +21,7 @@ export class ShareService { constructor( private prisma: PrismaService, private fileService: FileService, + private emailService: EmailService, private config: ConfigService, private jwtService: JwtService ) {} @@ -36,7 +38,7 @@ export class ShareService { } // We have to add an exception for "never" (since moment won't like that) - let expirationDate; + let expirationDate: Date; if (share.expiration !== "never") { expirationDate = moment() .add( @@ -60,6 +62,11 @@ export class ShareService { expiration: expirationDate, creator: { connect: user ? { id: user.id } : undefined }, security: { create: share.security }, + recipients: { + create: share.recipients + ? share.recipients.map((email) => ({ email })) + : [], + }, }, }); } @@ -84,21 +91,33 @@ export class ShareService { } async complete(id: string) { + const share = await this.prisma.share.findUnique({ + where: { id }, + include: { files: true, recipients: true, creator: true }, + }); + if (await this.isShareCompleted(id)) throw new BadRequestException("Share already completed"); - const moreThanOneFileInShare = - (await this.prisma.file.findMany({ where: { shareId: id } })).length != 0; - - if (!moreThanOneFileInShare) + if (share.files.length == 0) throw new BadRequestException( "You need at least on file in your share to complete it." ); + // Asynchronously create a zip of all files this.createZip(id).then(() => this.prisma.share.update({ where: { id }, data: { isZipReady: true } }) ); + // Send email for each recepient + for (const recepient of share.recipients) { + await this.emailService.sendMail( + recepient.email, + share.id, + share.creator + ); + } + return await this.prisma.share.update({ where: { id }, data: { uploadLocked: true }, @@ -106,7 +125,7 @@ export class ShareService { } async getSharesByUser(userId: string) { - return await this.prisma.share.findMany({ + const shares = await this.prisma.share.findMany({ where: { creator: { id: userId }, uploadLocked: true, @@ -119,7 +138,17 @@ export class ShareService { orderBy: { expiration: "desc", }, + include: { recipients: true }, }); + + const sharesWithEmailRecipients = shares.map((share) => { + return { + ...share, + recipients: share.recipients.map((recipients) => recipients.email), + }; + }); + + return sharesWithEmailRecipients; } async get(id: string) { diff --git a/frontend/src/components/upload/modals/showCreateUploadModal.tsx b/frontend/src/components/upload/modals/showCreateUploadModal.tsx index 48f6809..cca6171 100644 --- a/frontend/src/components/upload/modals/showCreateUploadModal.tsx +++ b/frontend/src/components/upload/modals/showCreateUploadModal.tsx @@ -6,6 +6,7 @@ import { Col, Grid, Group, + MultiSelect, NumberInput, PasswordInput, Select, @@ -33,6 +34,7 @@ const showCreateUploadModal = ( uploadCallback: ( id: string, expiration: string, + recipients: string[], security: ShareSecurity ) => void ) => { @@ -54,6 +56,7 @@ const CreateUploadModalBody = ({ uploadCallback: ( id: string, expiration: string, + recipients: string[], security: ShareSecurity ) => void; isSignedIn: boolean; @@ -79,7 +82,7 @@ const CreateUploadModalBody = ({ const form = useForm({ initialValues: { link: "", - + recipients: [] as string[], password: undefined, maxViews: undefined, expiration_num: 1, @@ -110,7 +113,7 @@ const CreateUploadModalBody = ({ const expiration = form.values.never_expires ? "never" : form.values.expiration_num + form.values.expiration_unit; - uploadCallback(values.link, expiration, { + uploadCallback(values.link, expiration, values.recipients, { password: values.password, maxViews: values.maxViews, }); @@ -211,7 +214,6 @@ const CreateUploadModalBody = ({ label="Never Expires" {...form.getInputProps("never_expires")} /> - {/* Preview expiration date text */} {ExpirationPreview({ form })} - + + Email recipients + + `+ ${query}`} + onCreate={(query) => { + if (!query.match(/^\S+@\S+\.\S+$/)) { + form.setFieldError("recipients", "Invalid email address"); + } else { + form.setFieldError("recipients", null); + form.setFieldValue("recipients", [ + ...form.values.recipients, + query, + ]); + return query; + } + }} + /> + + Security options diff --git a/frontend/src/pages/upload.tsx b/frontend/src/pages/upload.tsx index 7090b69..9a2664e 100644 --- a/frontend/src/pages/upload.tsx +++ b/frontend/src/pages/upload.tsx @@ -29,6 +29,7 @@ const Upload = () => { const uploadFiles = async ( id: string, expiration: string, + recipients: string[], security: ShareSecurity ) => { setisUploading(true); @@ -39,7 +40,7 @@ const Upload = () => { return file; }) ); - share = await shareService.create(id, expiration, security); + share = await shareService.create(id, expiration, recipients, security); for (let i = 0; i < files.length; i++) { const progressCallBack = (progress: number) => { setFiles((files) => { diff --git a/frontend/src/services/share.service.ts b/frontend/src/services/share.service.ts index fd8a698..cf91ff3 100644 --- a/frontend/src/services/share.service.ts +++ b/frontend/src/services/share.service.ts @@ -9,9 +9,11 @@ import api from "./api.service"; const create = async ( id: string, expiration: string, + recipients: string[], security?: ShareSecurity ) => { - return (await api.post("shares", { id, expiration, security })).data; + return (await api.post("shares", { id, expiration, recipients, security })) + .data; }; const completeShare = async (id: string) => {