Skip to content

Commit

Permalink
feat: add email recepients functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
stonith404 committed Nov 11, 2022
1 parent 0efd2d8 commit 32ad43a
Show file tree
Hide file tree
Showing 15 changed files with 192 additions and 18 deletions.
7 changes: 7 additions & 0 deletions .env.example
Expand Up @@ -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
7 changes: 7 additions & 0 deletions backend/.env.example
Expand Up @@ -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
33 changes: 33 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions backend/package.json
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
17 changes: 13 additions & 4 deletions backend/prisma/schema.prisma
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Expand Up @@ -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({
Expand Down
8 changes: 8 additions & 0 deletions 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 {}
35 changes: 35 additions & 0 deletions 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 🐧`,
});
}
}
11 changes: 10 additions & 1 deletion 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 {
Expand All @@ -13,6 +19,9 @@ export class CreateShareDTO {
@IsString()
expiration: string;

@IsEmail({}, { each: true })
recipients: string[];

@ValidateNested()
@Type(() => ShareSecurityDTO)
security: ShareSecurityDTO;
Expand Down
3 changes: 3 additions & 0 deletions backend/src/share/dto/myShare.dto.ts
Expand Up @@ -8,6 +8,9 @@ export class MyShareDTO extends ShareDTO {
@Expose()
createdAt: Date;

@Expose()
recipients: string[];

from(partial: Partial<MyShareDTO>) {
return plainToClass(MyShareDTO, partial, { excludeExtraneousValues: true });
}
Expand Down
3 changes: 2 additions & 1 deletion 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],
Expand Down
41 changes: 35 additions & 6 deletions backend/src/share/share.service.ts
Expand Up @@ -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";
Expand All @@ -20,6 +21,7 @@ export class ShareService {
constructor(
private prisma: PrismaService,
private fileService: FileService,
private emailService: EmailService,
private config: ConfigService,
private jwtService: JwtService
) {}
Expand All @@ -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(
Expand All @@ -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 }))
: [],
},
},
});
}
Expand All @@ -84,29 +91,41 @@ 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 },
});
}

async getSharesByUser(userId: string) {
return await this.prisma.share.findMany({
const shares = await this.prisma.share.findMany({
where: {
creator: { id: userId },
uploadLocked: true,
Expand All @@ -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) {
Expand Down

0 comments on commit 32ad43a

Please sign in to comment.