mirror of
https://github.com/swissmakers/swiss-datashare.git
synced 2026-04-11 10:27:01 +02:00
feat: add email recepients functionality
This commit is contained in:
@@ -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
backend/package-lock.json
generated
33
backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
8
backend/src/email/email.module.ts
Normal file
8
backend/src/email/email.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { EmailService } from "./email.service";
|
||||
|
||||
@Module({
|
||||
providers: [EmailService],
|
||||
exports: [EmailService],
|
||||
})
|
||||
export class EmailModule {}
|
||||
35
backend/src/email/email.service.ts
Normal file
35
backend/src/email/email.service.ts
Normal file
@@ -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 🐧`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user