├── packages ├── server │ ├── tmp │ │ └── .gitkeep │ ├── .dockerignore │ ├── .eslintignore │ ├── src │ │ ├── config │ │ │ ├── auth.ts │ │ │ ├── sentry.ts │ │ │ ├── redis.ts │ │ │ ├── queue.ts │ │ │ ├── mongo.ts │ │ │ ├── upload.ts │ │ │ ├── logger.ts │ │ │ └── mail.ts │ │ ├── shared │ │ │ ├── core │ │ │ │ └── Service.ts │ │ │ ├── adapters │ │ │ │ ├── models │ │ │ │ │ ├── LoggerProvider.ts │ │ │ │ │ ├── QueueProvider.ts │ │ │ │ │ └── MailProvider.ts │ │ │ │ ├── implementations │ │ │ │ │ ├── mail │ │ │ │ │ │ ├── FakeProvider.ts │ │ │ │ │ │ ├── MailtrapProvider.ts │ │ │ │ │ │ └── SESProvider.ts │ │ │ │ │ ├── logger │ │ │ │ │ │ └── WinstonProvider.ts │ │ │ │ │ └── queue │ │ │ │ │ │ └── BullProvider.ts │ │ │ │ ├── providers.ts │ │ │ │ └── index.ts │ │ │ ├── infra │ │ │ │ ├── http │ │ │ │ │ ├── server.ts │ │ │ │ │ ├── middlewares │ │ │ │ │ │ └── ensureAuthenticated.ts │ │ │ │ │ ├── api │ │ │ │ │ │ └── v1.ts │ │ │ │ │ └── app.ts │ │ │ │ ├── mongoose │ │ │ │ │ └── connection.ts │ │ │ │ └── queue │ │ │ │ │ └── runner.ts │ │ │ ├── tests │ │ │ │ ├── setupTests.ts │ │ │ │ └── MongoMock.ts │ │ │ └── errors │ │ │ │ └── TokenExpiredError.ts │ │ └── modules │ │ │ ├── messages │ │ │ ├── dtos │ │ │ │ └── MessageJob.ts │ │ │ ├── services │ │ │ │ ├── CreateMessageService.ts │ │ │ │ ├── DeleteTemplateService.ts │ │ │ │ ├── CreateTemplateService.ts │ │ │ │ ├── DeleteMessageService.ts │ │ │ │ ├── GetMessageService.ts │ │ │ │ ├── GetTemplateService.ts │ │ │ │ ├── UpdateTemplateService.ts │ │ │ │ ├── SearchTemplatesService.ts │ │ │ │ ├── SearchMessagesService.ts │ │ │ │ ├── ParseTemplateService.ts │ │ │ │ ├── CreateMessageService.spec.ts │ │ │ │ ├── ParseTemplateService.spec.ts │ │ │ │ ├── CreateTemplateService.spec.ts │ │ │ │ ├── SendMessageService.ts │ │ │ │ ├── ProcessQueueService.ts │ │ │ │ ├── SendMessageService.spec.ts │ │ │ │ └── ProcessQueueService.spec.ts │ │ │ └── infra │ │ │ │ ├── mongoose │ │ │ │ └── schemas │ │ │ │ │ ├── Template.ts │ │ │ │ │ ├── Recipient.ts │ │ │ │ │ └── Message.ts │ │ │ │ └── http │ │ │ │ └── routes │ │ │ │ ├── template.ts │ │ │ │ └── message.ts │ │ │ ├── contacts │ │ │ ├── services │ │ │ │ ├── DeleteContactService.ts │ │ │ │ ├── SearchTagsService.ts │ │ │ │ ├── GetRecipientsFromTags.ts │ │ │ │ ├── ChangeContactSubscriptionStatusService.ts │ │ │ │ ├── ChangeTagTitleService.ts │ │ │ │ ├── ChangeContactEmailService.ts │ │ │ │ ├── SearchContactsService.ts │ │ │ │ ├── ImportContactsService.ts │ │ │ │ ├── ChangeContactSubscriptionStatusService.spec.ts │ │ │ │ ├── RemoveContactFromTag.ts │ │ │ │ ├── AddContactInTeamService.ts │ │ │ │ ├── CreateContactService.ts │ │ │ │ ├── CreateContactService.spec.ts │ │ │ │ └── ImportContactsService.spec.ts │ │ │ ├── infra │ │ │ │ ├── mongoose │ │ │ │ │ └── schemas │ │ │ │ │ │ ├── Tag.ts │ │ │ │ │ │ └── Contact.ts │ │ │ │ └── http │ │ │ │ │ └── routes │ │ │ │ │ ├── tag.ts │ │ │ │ │ └── contact.ts │ │ │ └── views │ │ │ │ └── unsubscribed.html │ │ │ ├── senders │ │ │ ├── services │ │ │ │ ├── GetSenderService.ts │ │ │ │ ├── CreateSenderService.ts │ │ │ │ ├── SearchSendersService.ts │ │ │ │ ├── UpdateSenderService.ts │ │ │ │ ├── DeleteSenderService.ts │ │ │ │ └── CreateSenderService.spec.ts │ │ │ └── infra │ │ │ │ ├── mongoose │ │ │ │ └── schemas │ │ │ │ │ └── Sender.ts │ │ │ │ └── http │ │ │ │ └── routes │ │ │ │ └── sender.ts │ │ │ └── users │ │ │ ├── infra │ │ │ ├── mongoose │ │ │ │ └── schemas │ │ │ │ │ └── User.ts │ │ │ └── http │ │ │ │ └── routes │ │ │ │ └── session.ts │ │ │ └── services │ │ │ ├── AuthenticateService.spec.ts │ │ │ └── AuthenticateService.ts │ ├── prettier.config.js │ ├── process.yml │ ├── .gitignore │ ├── jest-mongodb-config.js │ ├── .env.example │ ├── .vscode │ │ └── launch.json │ ├── Dockerfile │ ├── tsconfig.json │ ├── LICENSE │ ├── docker-compose.yml │ ├── .eslintrc.json │ └── package.json └── web │ ├── src │ ├── react-app-env.d.ts │ ├── components │ │ ├── Box │ │ │ └── index.tsx │ │ ├── Form │ │ │ ├── Form │ │ │ │ └── index.tsx │ │ │ ├── index.ts │ │ │ ├── Input │ │ │ │ ├── styles.ts │ │ │ │ └── index.tsx │ │ │ ├── CodeInput │ │ │ │ ├── styles.ts │ │ │ │ └── index.tsx │ │ │ ├── Select │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ │ ├── CreatableSelect │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ │ └── AsyncSelect │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ ├── Tag │ │ │ └── index.tsx │ │ ├── Sidebar │ │ │ ├── index.tsx │ │ │ └── styles.ts │ │ ├── PaginatedTable │ │ │ ├── styles.ts │ │ │ └── index.tsx │ │ └── Button │ │ │ └── index.tsx │ ├── services │ │ ├── history.ts │ │ ├── axios.ts │ │ ├── usePaginatedRequest.ts │ │ └── useRequest.ts │ ├── index.tsx │ ├── pages │ │ ├── _layouts │ │ │ ├── auth.tsx │ │ │ └── default.tsx │ │ ├── SenderList │ │ │ ├── styles.ts │ │ │ └── index.tsx │ │ ├── TemplateList │ │ │ ├── styles.ts │ │ │ └── index.tsx │ │ ├── MessageForm │ │ │ ├── styles.ts │ │ │ └── index.tsx │ │ ├── SenderForm │ │ │ ├── styles.ts │ │ │ └── index.tsx │ │ ├── TemplateForm │ │ │ ├── styles.ts │ │ │ └── index.tsx │ │ ├── SignIn │ │ │ ├── styles.ts │ │ │ └── index.tsx │ │ ├── Contacts │ │ │ ├── styles.ts │ │ │ ├── Import │ │ │ │ ├── styles.ts │ │ │ │ └── index.tsx │ │ │ └── index.tsx │ │ ├── MessageList │ │ │ ├── styles.ts │ │ │ └── index.tsx │ │ └── MessageDetail │ │ │ ├── styles.ts │ │ │ └── index.tsx │ ├── App.tsx │ ├── styles │ │ └── global.ts │ └── routes │ │ ├── Route.tsx │ │ └── main.routes.tsx │ ├── public │ ├── robots.txt │ ├── favicon.png │ └── index.html │ ├── .eslintignore │ ├── prettier.config.js │ ├── .editorconfig │ ├── .gitignore │ ├── tsconfig.json │ ├── .eslintrc.json │ └── package.json ├── .npmrc ├── README.md ├── .gitignore ├── commitlint.config.js ├── .eslintignore ├── prettier.config.js ├── .editorconfig ├── renovate.json ├── jest.config.js ├── tsconfig.json ├── .github └── workflows │ ├── lint.yml │ ├── tsc.yml │ ├── server.yml │ └── web.yml ├── .eslintrc.json └── package.json /packages/server/tmp/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save=true 2 | save-exact=true -------------------------------------------------------------------------------- /packages/server/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /packages/server/.eslintignore: -------------------------------------------------------------------------------- 1 | /*.js 2 | node_modules 3 | dist -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Umbriel 2 | 3 | Batch mailing with Node.js & Amazon SES. 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | dist 3 | yarn-error.log 4 | globalConfig.json 5 | -------------------------------------------------------------------------------- /packages/web/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'] 3 | } -------------------------------------------------------------------------------- /packages/web/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/*.js 2 | /*.js 3 | node_modules 4 | build 5 | /packages/web/src/react-app-env.d.ts 6 | -------------------------------------------------------------------------------- /packages/web/.eslintignore: -------------------------------------------------------------------------------- 1 | **/*.js 2 | /*.js 3 | node_modules 4 | build 5 | /src/react-app-env.d.ts 6 | -------------------------------------------------------------------------------- /packages/web/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rocketseat/umbriel/HEAD/packages/web/public/favicon.png -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | trailingComma: 'all', 4 | arrowParens: 'avoid', 5 | } 6 | -------------------------------------------------------------------------------- /packages/server/src/config/auth.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | secret: process.env.APP_SECRET || '', 3 | 4 | expiresIn: '1d', 5 | }; 6 | -------------------------------------------------------------------------------- /packages/web/prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | trailingComma: 'all', 4 | arrowParens: 'avoid', 5 | }; 6 | -------------------------------------------------------------------------------- /packages/server/prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | trailingComma: 'all', 4 | arrowParens: 'avoid', 5 | }; 6 | -------------------------------------------------------------------------------- /packages/web/src/components/Box/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default styled.div` 4 | width: 100%; 5 | `; 6 | -------------------------------------------------------------------------------- /packages/server/src/shared/core/Service.ts: -------------------------------------------------------------------------------- 1 | export default interface Service { 2 | execute(request?: IRequest): Promise | IResponse; 3 | } 4 | -------------------------------------------------------------------------------- /packages/server/process.yml: -------------------------------------------------------------------------------- 1 | apps: 2 | - script : shared/infra/http/server.js 3 | name : server 4 | - script : shared/infra/queue/runner.js 5 | name : queue 6 | -------------------------------------------------------------------------------- /packages/server/src/shared/adapters/models/LoggerProvider.ts: -------------------------------------------------------------------------------- 1 | export default interface LoggerProvider { 2 | log(level: string, message: string, metadata?: object): void; 3 | } 4 | -------------------------------------------------------------------------------- /packages/web/src/services/history.ts: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory, History } from 'history'; 2 | 3 | const history: History = createBrowserHistory(); 4 | 5 | export default history; 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true -------------------------------------------------------------------------------- /packages/web/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | ReactDOM.render(, document.getElementById('root')); 6 | -------------------------------------------------------------------------------- /packages/server/.gitignore: -------------------------------------------------------------------------------- 1 | # Application 2 | .env 3 | tmp/* 4 | !tmp/.gitkeep 5 | 6 | # Yarn 7 | node_modules 8 | yarn-error.log 9 | 10 | # Jest 11 | coverage 12 | 13 | # Dist 14 | dist/ -------------------------------------------------------------------------------- /packages/server/src/shared/infra/http/server.ts: -------------------------------------------------------------------------------- 1 | import app from './app'; 2 | 3 | app.listen(process.env.PORT || 3333, () => { 4 | console.log('⚡️ Server listening on http://localhost:3333'); 5 | }); 6 | -------------------------------------------------------------------------------- /packages/web/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true -------------------------------------------------------------------------------- /packages/server/src/config/sentry.ts: -------------------------------------------------------------------------------- 1 | import { NodeOptions } from '@sentry/node'; 2 | 3 | type SentryConfig = NodeOptions; 4 | 5 | export default { 6 | dsn: process.env.SENTRY_DSN, 7 | } as SentryConfig; 8 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "semanticCommits": true, 4 | "labels": ["dependencies"], 5 | "stabilityDays": 3, 6 | "prCreation": "not-pending", 7 | "dependencyDashboard": true 8 | } 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | preset: 'ts-jest', 4 | projects: ['/packages/**/jest.config.js'], 5 | testEnvironment: 'node', 6 | testMatch: ['*.spec.ts', '*.spec.tsx'] 7 | } 8 | -------------------------------------------------------------------------------- /packages/web/src/components/Form/Form/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { Form } from '@unform/web'; 3 | 4 | export default styled(Form)` 5 | button { 6 | display: block; 7 | width: 100%; 8 | } 9 | `; 10 | -------------------------------------------------------------------------------- /packages/server/src/shared/adapters/models/QueueProvider.ts: -------------------------------------------------------------------------------- 1 | interface Job { 2 | data: object; 3 | } 4 | 5 | export default interface QueueProvider { 6 | add(data: object): Promise; 7 | process(processFunction: (job: Job) => Promise): void; 8 | } 9 | -------------------------------------------------------------------------------- /packages/server/src/shared/tests/setupTests.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import 'dotenv/config'; 3 | import { container } from 'tsyringe'; 4 | 5 | container.register('LoggerProvider', { 6 | useValue: { 7 | log: jest.fn(), 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /packages/server/jest-mongodb-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mongodbMemoryServerOptions: { 3 | instance: { 4 | dbName: 'jest' 5 | }, 6 | binary: { 7 | version: '4.0.3', 8 | skipMD5: true 9 | }, 10 | autoStart: false 11 | } 12 | }; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "compilerOptions": { 4 | "target": "es2017", 5 | "module": "commonjs", 6 | "noEmit": true, 7 | "resolveJsonModule": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/server/src/config/redis.ts: -------------------------------------------------------------------------------- 1 | import { RedisOptions } from 'ioredis'; 2 | 3 | type RedisConfig = RedisOptions; 4 | 5 | export default { 6 | host: process.env.REDIS_URL || '127.0.0.1', 7 | port: Number(process.env.REDIS_PORT) || 6379, 8 | password: process.env.REDIS_PASS, 9 | } as RedisConfig; 10 | -------------------------------------------------------------------------------- /packages/server/src/config/queue.ts: -------------------------------------------------------------------------------- 1 | import { QueueOptions } from 'bull'; 2 | 3 | interface QueueConfig { 4 | driver: 'bull'; 5 | 6 | config: { 7 | bull: QueueOptions; 8 | }; 9 | } 10 | 11 | export default { 12 | driver: 'bull', 13 | 14 | config: { 15 | bull: {}, 16 | }, 17 | } as QueueConfig; 18 | -------------------------------------------------------------------------------- /packages/web/src/pages/_layouts/auth.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import GlobalStyle from '../../styles/global'; 3 | 4 | const AuthLayout: React.FC = ({ children }) => { 5 | return ( 6 | <> 7 | {children} 8 | 9 | 10 | ); 11 | }; 12 | 13 | export default AuthLayout; 14 | -------------------------------------------------------------------------------- /packages/server/src/shared/adapters/models/MailProvider.ts: -------------------------------------------------------------------------------- 1 | interface Message { 2 | from: { 3 | name: string; 4 | email: string; 5 | }; 6 | to: string; 7 | subject: string; 8 | body: string; 9 | } 10 | 11 | export default interface MailProvider { 12 | sendEmail(message: Message): Promise; 13 | } 14 | -------------------------------------------------------------------------------- /packages/server/src/modules/messages/dtos/MessageJob.ts: -------------------------------------------------------------------------------- 1 | import { ContactDocument } from '@modules/contacts/infra/mongoose/schemas/Contact'; 2 | import { MessageDocument } from '@modules/messages/infra/mongoose/schemas/Message'; 3 | 4 | export default interface MessageJob { 5 | contact: ContactDocument; 6 | message: MessageDocument; 7 | } 8 | -------------------------------------------------------------------------------- /packages/web/src/pages/SenderList/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.main` 4 | width: 100%; 5 | padding: 30px; 6 | 7 | > header { 8 | display: flex; 9 | justify-content: space-between; 10 | align-items: center; 11 | margin-bottom: 15px; 12 | } 13 | `; 14 | -------------------------------------------------------------------------------- /packages/web/src/pages/TemplateList/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.main` 4 | width: 100%; 5 | padding: 30px; 6 | 7 | > header { 8 | display: flex; 9 | justify-content: space-between; 10 | align-items: center; 11 | margin-bottom: 15px; 12 | } 13 | `; 14 | -------------------------------------------------------------------------------- /packages/web/src/components/Form/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Form } from './Form'; 2 | export { default as Input } from './Input'; 3 | export { default as CodeInput } from './CodeInput'; 4 | export { default as AsyncSelect } from './AsyncSelect'; 5 | export { default as CreatableSelect } from './CreatableSelect'; 6 | export { default as Select } from './Select'; 7 | -------------------------------------------------------------------------------- /packages/web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter } from 'react-router-dom'; 3 | 4 | import Routes from './routes/main.routes'; 5 | 6 | const App: React.FC = () => { 7 | return ( 8 | 9 |
10 | 11 |
12 |
13 | ); 14 | }; 15 | 16 | export default App; 17 | -------------------------------------------------------------------------------- /packages/server/.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | APP_SECRET=myappsecret 3 | APP_URL=http://localhost:3333 4 | 5 | # AWS 6 | AWS_ACCESS_KEY_ID= 7 | AWS_SECRET_ACCESS_KEY= 8 | 9 | # Mongo 10 | MONGO_USER= 11 | MONGO_PASS= 12 | MONGO_DB=umbriel 13 | 14 | # Mailtrap 15 | MAILTRAP_USER= 16 | MAILTRAP_PASS= 17 | 18 | # Redis 19 | REDIS_PASS= 20 | 21 | # Sentry 22 | SENTRY_DSN= 23 | -------------------------------------------------------------------------------- /packages/web/src/pages/MessageForm/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.main` 4 | width: 100%; 5 | padding: 30px; 6 | 7 | > header { 8 | display: flex; 9 | margin-bottom: 15px; 10 | 11 | > h1 { 12 | margin-right: auto; 13 | } 14 | 15 | button { 16 | margin-left: 10px; 17 | } 18 | } 19 | `; 20 | -------------------------------------------------------------------------------- /packages/web/src/pages/SenderForm/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.main` 4 | width: 100%; 5 | padding: 30px; 6 | 7 | > header { 8 | display: flex; 9 | margin-bottom: 15px; 10 | 11 | > h1 { 12 | margin-right: auto; 13 | } 14 | 15 | button { 16 | margin-left: 10px; 17 | } 18 | } 19 | `; 20 | -------------------------------------------------------------------------------- /packages/web/src/pages/TemplateForm/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.main` 4 | width: 100%; 5 | padding: 30px; 6 | 7 | > header { 8 | display: flex; 9 | margin-bottom: 15px; 10 | 11 | > h1 { 12 | margin-right: auto; 13 | } 14 | 15 | button { 16 | margin-left: 10px; 17 | } 18 | } 19 | `; 20 | -------------------------------------------------------------------------------- /packages/web/src/pages/_layouts/default.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import GlobalStyle from '../../styles/global'; 3 | 4 | import Sidebar from '../../components/Sidebar'; 5 | 6 | const DefaultLayout: React.FC = ({ children }) => { 7 | return ( 8 | <> 9 | 10 | {children} 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default DefaultLayout; 17 | -------------------------------------------------------------------------------- /packages/server/src/shared/errors/TokenExpiredError.ts: -------------------------------------------------------------------------------- 1 | export default class TokenExpiredError { 2 | public message: string; 3 | 4 | public data: object; 5 | 6 | public statusCode: number; 7 | 8 | constructor(message: string, data?: object, statusCode = 501) { 9 | this.message = message; 10 | this.statusCode = statusCode; 11 | 12 | if (data) { 13 | this.data = data; 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/server/src/config/mongo.ts: -------------------------------------------------------------------------------- 1 | interface MongoConfig { 2 | host: string; 3 | port: number; 4 | username?: string; 5 | password?: string; 6 | database: string; 7 | } 8 | 9 | export default { 10 | host: process.env.MONGO_URL || 'localhost', 11 | port: process.env.MONGO_PORT || 27017, 12 | username: process.env.MONGO_USER, 13 | password: process.env.MONGO_PASS, 14 | database: process.env.MONGO_DB, 15 | } as MongoConfig; 16 | -------------------------------------------------------------------------------- /packages/web/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | .env 26 | -------------------------------------------------------------------------------- /packages/web/src/components/Tag/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | interface TagProps { 4 | color?: string; 5 | } 6 | 7 | export default styled.span` 8 | background: ${props => props.color || '#FF79C6'}; 9 | color: #fff; 10 | padding: 4px 6px; 11 | border-radius: 3px; 12 | font-size: 10px; 13 | font-weight: 500; 14 | display: inline-block; 15 | font-weight: bold; 16 | text-transform: uppercase; 17 | `; 18 | -------------------------------------------------------------------------------- /packages/server/src/shared/adapters/implementations/mail/FakeProvider.ts: -------------------------------------------------------------------------------- 1 | import MailProvider from '../../models/MailProvider'; 2 | 3 | interface Message { 4 | from: { 5 | name: string; 6 | email: string; 7 | }; 8 | to: string; 9 | subject: string; 10 | body: string; 11 | } 12 | 13 | class FakeProvider implements MailProvider { 14 | async sendEmail(data: Message): Promise { 15 | console.log(data); 16 | } 17 | } 18 | 19 | export default FakeProvider; 20 | -------------------------------------------------------------------------------- /packages/server/src/shared/infra/mongoose/connection.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | import mongoConfig from '@config/mongo'; 4 | 5 | const mongoUserPass = mongoConfig.username 6 | ? `${mongoConfig.username}:${mongoConfig.password}@` 7 | : ''; 8 | 9 | mongoose.connect( 10 | `mongodb://${mongoUserPass}${mongoConfig.host}:${mongoConfig.port}/${mongoConfig.database}`, 11 | { 12 | useNewUrlParser: true, 13 | useUnifiedTopology: true, 14 | useCreateIndex: true, 15 | useFindAndModify: false, 16 | }, 17 | ); 18 | -------------------------------------------------------------------------------- /packages/server/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "restart": true, 10 | "name": "Launch & Debug", 11 | "skipFiles": [ 12 | "/**" 13 | ], 14 | "protocol": "inspector", 15 | "request": "attach" 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /packages/server/src/modules/contacts/services/DeleteContactService.ts: -------------------------------------------------------------------------------- 1 | import Contact from '@modules/contacts/infra/mongoose/schemas/Contact'; 2 | 3 | import Service from '@shared/core/Service'; 4 | 5 | class DeleteContactService implements Service { 6 | async execute(id: string): Promise { 7 | const contact = await Contact.findById(id); 8 | 9 | if (!contact) { 10 | throw new Error('Message not found'); 11 | } 12 | 13 | await contact.remove(); 14 | } 15 | } 16 | 17 | export default DeleteContactService; 18 | -------------------------------------------------------------------------------- /packages/server/src/modules/messages/services/CreateMessageService.ts: -------------------------------------------------------------------------------- 1 | import Message, { 2 | MessageDocument, 3 | MessageAttributes, 4 | } from '@modules/messages/infra/mongoose/schemas/Message'; 5 | 6 | import Service from '@shared/core/Service'; 7 | 8 | interface Request { 9 | data: MessageAttributes; 10 | } 11 | 12 | class CreateMessageService implements Service { 13 | execute({ data }: Request): Promise { 14 | return Message.create(data); 15 | } 16 | } 17 | 18 | export default CreateMessageService; 19 | -------------------------------------------------------------------------------- /packages/server/src/modules/messages/services/DeleteTemplateService.ts: -------------------------------------------------------------------------------- 1 | import Template from '@modules/messages/infra/mongoose/schemas/Template'; 2 | 3 | import Service from '@shared/core/Service'; 4 | 5 | class DeleteTemplateService implements Service { 6 | async execute(id: string): Promise { 7 | const template = await Template.findById(id); 8 | 9 | if (!template) { 10 | throw new Error('Template not found'); 11 | } 12 | 13 | await template.remove(); 14 | } 15 | } 16 | 17 | export default DeleteTemplateService; 18 | -------------------------------------------------------------------------------- /packages/server/src/modules/messages/services/CreateTemplateService.ts: -------------------------------------------------------------------------------- 1 | import Template, { 2 | TemplateAttributes, 3 | TemplateDocument, 4 | } from '@modules/messages/infra/mongoose/schemas/Template'; 5 | 6 | import Service from '@shared/core/Service'; 7 | 8 | interface Request { 9 | data: TemplateAttributes; 10 | } 11 | 12 | class SaveTemplateService implements Service { 13 | execute({ data }: Request): Promise { 14 | return Template.create(data); 15 | } 16 | } 17 | 18 | export default SaveTemplateService; 19 | -------------------------------------------------------------------------------- /packages/web/src/pages/SignIn/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.div` 4 | background: #191622; 5 | height: 100vh; 6 | display: flex; 7 | align-items: center; 8 | justify-content: center; 9 | margin: 0 auto; 10 | width: 100%; 11 | max-width: 320px; 12 | `; 13 | 14 | export const Content = styled.div` 15 | width: 100%; 16 | max-width: 320px; 17 | 18 | display: flex; 19 | flex-direction: column; 20 | align-items: stretch; 21 | 22 | img { 23 | margin-bottom: 30px; 24 | } 25 | `; 26 | -------------------------------------------------------------------------------- /packages/server/src/shared/adapters/implementations/logger/WinstonProvider.ts: -------------------------------------------------------------------------------- 1 | import { createLogger, LoggerOptions, Logger } from 'winston'; 2 | 3 | import LoggerProvider from '../../models/LoggerProvider'; 4 | 5 | class WinstonProvider implements LoggerProvider { 6 | private logger: Logger; 7 | 8 | constructor(config: LoggerOptions) { 9 | this.logger = createLogger(config); 10 | } 11 | 12 | log(level: string, message: string, metadata: object): void { 13 | this.logger.log(level, message, { metadata }); 14 | } 15 | } 16 | 17 | export default WinstonProvider; 18 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | code: 11 | name: Lint code 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v2 16 | 17 | - name: Use Node.js 12 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: "12" 21 | 22 | - name: Install all dependencies 23 | uses: Borales/actions-yarn@v2.3.0 24 | 25 | - name: Run ESLint 26 | run: yarn lint 27 | -------------------------------------------------------------------------------- /packages/server/src/modules/contacts/infra/mongoose/schemas/Tag.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document, Schema, Model } from 'mongoose'; 2 | 3 | export type TagDocument = Document & { 4 | title: string; 5 | }; 6 | 7 | type TagModel = Model; 8 | 9 | const TagSchema = new Schema( 10 | { 11 | title: { 12 | type: String, 13 | trim: true, 14 | unique: true, 15 | required: true, 16 | }, 17 | integrationId: String, 18 | }, 19 | { 20 | timestamps: true, 21 | }, 22 | ); 23 | 24 | export default mongoose.model('Tag', TagSchema); 25 | -------------------------------------------------------------------------------- /packages/server/src/modules/contacts/services/SearchTagsService.ts: -------------------------------------------------------------------------------- 1 | import Tag, { TagDocument } from '@modules/contacts/infra/mongoose/schemas/Tag'; 2 | 3 | import Service from '@shared/core/Service'; 4 | 5 | interface Request { 6 | search: string; 7 | } 8 | 9 | type Response = TagDocument[]; 10 | 11 | class SearchTagsService implements Service { 12 | async execute({ search }: Request): Promise { 13 | const tags = await Tag.find({ 14 | title: new RegExp(`${search.toLowerCase()}`, 'i'), 15 | }); 16 | 17 | return tags; 18 | } 19 | } 20 | 21 | export default SearchTagsService; 22 | -------------------------------------------------------------------------------- /packages/server/src/modules/contacts/services/GetRecipientsFromTags.ts: -------------------------------------------------------------------------------- 1 | import Service from '@shared/core/Service'; 2 | 3 | import Contact, { ContactDocument } from '../infra/mongoose/schemas/Contact'; 4 | 5 | interface Request { 6 | tags: string[]; 7 | } 8 | 9 | type Response = ContactDocument[]; 10 | 11 | class GetRecipientsFromTags implements Service { 12 | async execute({ tags }: Request): Promise { 13 | const recipients = await Contact.findByTags(tags, { 14 | subscribed: true, 15 | }); 16 | 17 | return recipients; 18 | } 19 | } 20 | 21 | export default GetRecipientsFromTags; 22 | -------------------------------------------------------------------------------- /packages/server/src/shared/infra/queue/runner.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import 'reflect-metadata'; 3 | import { container } from 'tsyringe'; 4 | 5 | import * as Sentry from '@sentry/node'; 6 | 7 | import '@shared/adapters'; 8 | import '@shared/infra/mongoose/connection'; 9 | 10 | import ProcessQueueService from '@modules/messages/services/ProcessQueueService'; 11 | 12 | import sentryConfig from '@config/sentry'; 13 | 14 | Sentry.init({ dsn: sentryConfig.dsn }); 15 | 16 | const processQueue = container.resolve(ProcessQueueService); 17 | 18 | processQueue.execute(); 19 | 20 | console.log('⚗‎‎ Processing mail sending queue!'); 21 | -------------------------------------------------------------------------------- /packages/server/src/modules/senders/services/GetSenderService.ts: -------------------------------------------------------------------------------- 1 | import Sender, { 2 | SenderDocument, 3 | } from '@modules/senders/infra/mongoose/schemas/Sender'; 4 | 5 | import Service from '@shared/core/Service'; 6 | 7 | type Request = string; 8 | 9 | type Response = SenderDocument; 10 | 11 | class GetSenderService implements Service { 12 | async execute(id: Request): Promise { 13 | const sender = await Sender.findById(id); 14 | 15 | if (!sender) { 16 | throw new Error('Sender not found.'); 17 | } 18 | 19 | return sender; 20 | } 21 | } 22 | 23 | export default GetSenderService; 24 | -------------------------------------------------------------------------------- /packages/server/src/modules/messages/services/DeleteMessageService.ts: -------------------------------------------------------------------------------- 1 | import Message from '@modules/messages/infra/mongoose/schemas/Message'; 2 | 3 | import Service from '@shared/core/Service'; 4 | 5 | class DeleteMessageService implements Service { 6 | async execute(id: string): Promise { 7 | const message = await Message.findById(id); 8 | 9 | if (!message) { 10 | throw new Error('Message not found'); 11 | } 12 | 13 | if (message.sentAt) { 14 | throw new Error('Message already sent'); 15 | } 16 | 17 | await message.remove(); 18 | } 19 | } 20 | 21 | export default DeleteMessageService; 22 | -------------------------------------------------------------------------------- /packages/server/src/modules/messages/services/GetMessageService.ts: -------------------------------------------------------------------------------- 1 | import Message, { 2 | MessageDocument, 3 | } from '@modules/messages/infra/mongoose/schemas/Message'; 4 | 5 | import Service from '@shared/core/Service'; 6 | 7 | type Request = string; 8 | 9 | type Response = MessageDocument; 10 | 11 | class GetMessageService implements Service { 12 | async execute(id: Request): Promise { 13 | const message = await Message.findById(id); 14 | 15 | if (!message) { 16 | throw new Error('Message not found.'); 17 | } 18 | 19 | return message; 20 | } 21 | } 22 | 23 | export default GetMessageService; 24 | -------------------------------------------------------------------------------- /packages/server/src/modules/messages/services/GetTemplateService.ts: -------------------------------------------------------------------------------- 1 | import Template, { 2 | TemplateDocument, 3 | } from '@modules/messages/infra/mongoose/schemas/Template'; 4 | 5 | import Service from '@shared/core/Service'; 6 | 7 | type Request = string; 8 | 9 | type Response = TemplateDocument; 10 | 11 | class GetTemplateService implements Service { 12 | async execute(id: Request): Promise { 13 | const template = await Template.findById(id); 14 | 15 | if (!template) { 16 | throw new Error('Template not found.'); 17 | } 18 | 19 | return template; 20 | } 21 | } 22 | 23 | export default GetTemplateService; 24 | -------------------------------------------------------------------------------- /packages/web/src/services/axios.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import history from './history'; 3 | 4 | const api = axios.create({ 5 | baseURL: process.env.REACT_APP_API_URL, 6 | }); 7 | 8 | api.interceptors.request.use(config => { 9 | config.headers.authorization = `Bearer ${localStorage.getItem( 10 | '@Umbriel:token', 11 | )}`; 12 | 13 | return config; 14 | }); 15 | 16 | api.interceptors.response.use( 17 | config => config, 18 | error => { 19 | const { data } = error.response; 20 | 21 | if (data?.code === 'token.expired') { 22 | localStorage.clear(); 23 | 24 | history.push('/'); 25 | } 26 | }, 27 | ); 28 | 29 | export default api; 30 | -------------------------------------------------------------------------------- /packages/server/src/shared/tests/MongoMock.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Mongoose } from 'mongoose'; 2 | 3 | class MongoMock { 4 | private database: Mongoose; 5 | 6 | async connect(): Promise { 7 | if (!process.env.MONGO_URL) { 8 | throw new Error('MongoDB server not initialized'); 9 | } 10 | 11 | this.database = await mongoose.connect(process.env.MONGO_URL, { 12 | useNewUrlParser: true, 13 | useUnifiedTopology: true, 14 | useCreateIndex: true, 15 | useFindAndModify: false, 16 | }); 17 | } 18 | 19 | disconnect(): Promise { 20 | return this.database.connection.close(); 21 | } 22 | } 23 | 24 | export default new MongoMock(); 25 | -------------------------------------------------------------------------------- /packages/server/src/shared/adapters/providers.ts: -------------------------------------------------------------------------------- 1 | import WinstonProvider from './implementations/logger/WinstonProvider'; 2 | import FakeProvider from './implementations/mail/FakeProvider'; 3 | import MailtrapProvider from './implementations/mail/MailtrapProvider'; 4 | import SESProvider from './implementations/mail/SESProvider'; 5 | import BullProvider from './implementations/queue/BullProvider'; 6 | 7 | const providers = { 8 | mail: { 9 | ses: SESProvider, 10 | mailtrap: MailtrapProvider, 11 | fake: FakeProvider, 12 | }, 13 | logger: { 14 | winston: WinstonProvider, 15 | }, 16 | queue: { 17 | bull: BullProvider, 18 | }, 19 | }; 20 | 21 | export default providers; 22 | -------------------------------------------------------------------------------- /packages/web/src/styles/global.ts: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components'; 2 | 3 | export default createGlobalStyle` 4 | * { 5 | margin: 0; 6 | padding: 0; 7 | box-sizing: border-box; 8 | outline: 0; 9 | } 10 | 11 | body { 12 | background: #191622; 13 | color: #E1E1E6; 14 | font: 14px Roboto, sans-serif; 15 | font-weight: 400; 16 | -webkit-font-smoothing: antialiased; 17 | } 18 | 19 | button { 20 | font: inherit; 21 | } 22 | 23 | input, textarea { 24 | font-size: 16px; 25 | } 26 | 27 | #app { 28 | display: flex; 29 | align-items: stretch; 30 | } 31 | 32 | h1, h2, h3, h4, h5 { 33 | color: #E1E1E6; 34 | } 35 | `; 36 | -------------------------------------------------------------------------------- /packages/server/src/config/upload.ts: -------------------------------------------------------------------------------- 1 | import multer from 'multer'; 2 | import path from 'path'; 3 | 4 | interface UploadConfig { 5 | driver: 'multer'; 6 | 7 | tmpDir: string; 8 | 9 | config: { 10 | multer: multer.Options; 11 | }; 12 | } 13 | 14 | export default { 15 | driver: 'multer', 16 | 17 | tmpDir: path.resolve(__dirname, '..', '..', 'tmp'), 18 | 19 | config: { 20 | multer: { 21 | storage: multer.diskStorage({ 22 | destination: path.resolve(__dirname, '..', '..', 'tmp'), 23 | filename(req, file, cb) { 24 | const now = Date.now(); 25 | 26 | cb(null, `${now}-${file.originalname}`); 27 | }, 28 | }), 29 | }, 30 | }, 31 | } as UploadConfig; 32 | -------------------------------------------------------------------------------- /.github/workflows/tsc.yml: -------------------------------------------------------------------------------- 1 | name: Type Checking 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | code: 11 | name: Type Checking 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v2 16 | 17 | - name: Use Node.js 12 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: "12" 21 | 22 | - name: Install all dependencies 23 | uses: Borales/actions-yarn@v2.3.0 24 | 25 | - name: Run type checking server 26 | run: cd packages/server && yarn tsc 27 | 28 | - name: Run type checking web 29 | run: cd packages/web && yarn tsc 30 | -------------------------------------------------------------------------------- /packages/web/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | Umbriel | Mail sending for dummies 14 | 15 | 16 | 17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /packages/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react", 21 | "baseUrl": "src", 22 | "typeRoots": [ 23 | "../../node_modules/@types", 24 | "src/@types" 25 | ] 26 | }, 27 | "include": [ 28 | "src/**/*" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /packages/server/src/modules/contacts/views/unsubscribed.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Inscrição removida 8 | 9 | 27 | 28 | 29 |

Inscrição removida

30 |

A partir de agora você não receberá mais nossos e-mails.

31 | 32 | -------------------------------------------------------------------------------- /packages/server/src/modules/messages/infra/mongoose/schemas/Template.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document, Schema, Model } from 'mongoose'; 2 | 3 | export type TemplateAttributes = { 4 | title: string; 5 | content: string; 6 | }; 7 | 8 | export type TemplateDocument = Document & TemplateAttributes; 9 | 10 | type TemplateModel = Model; 11 | 12 | const TemplateSchema = new Schema( 13 | { 14 | title: { 15 | type: String, 16 | trim: true, 17 | required: true, 18 | }, 19 | content: { 20 | type: String, 21 | required: true, 22 | }, 23 | }, 24 | { 25 | timestamps: true, 26 | }, 27 | ); 28 | 29 | export default mongoose.model( 30 | 'Template', 31 | TemplateSchema, 32 | ); 33 | -------------------------------------------------------------------------------- /packages/server/src/modules/senders/services/CreateSenderService.ts: -------------------------------------------------------------------------------- 1 | import Sender, { 2 | SenderAttributes, 3 | SenderDocument, 4 | } from '@modules/senders/infra/mongoose/schemas/Sender'; 5 | 6 | import Service from '@shared/core/Service'; 7 | 8 | interface Request { 9 | data: SenderAttributes; 10 | } 11 | 12 | class CreateSenderService implements Service { 13 | async execute({ data }: Request): Promise { 14 | const duplicatedSender = await Sender.findOne({ email: data.email }); 15 | 16 | if (duplicatedSender) { 17 | throw new Error('Duplicated sender'); 18 | } 19 | 20 | const sender = await Sender.create(data); 21 | 22 | return sender; 23 | } 24 | } 25 | 26 | export default CreateSenderService; 27 | -------------------------------------------------------------------------------- /packages/server/src/modules/messages/services/UpdateTemplateService.ts: -------------------------------------------------------------------------------- 1 | import Template, { 2 | TemplateDocument, 3 | TemplateAttributes, 4 | } from '@modules/messages/infra/mongoose/schemas/Template'; 5 | 6 | import Service from '@shared/core/Service'; 7 | 8 | interface Request { 9 | id: string; 10 | data: TemplateAttributes; 11 | } 12 | 13 | type Response = TemplateDocument; 14 | 15 | class UpdateTemplateService implements Service { 16 | async execute({ id, data }: Request): Promise { 17 | const template = await Template.findByIdAndUpdate(id, data, { new: true }); 18 | 19 | if (!template) { 20 | throw new Error('Template not found'); 21 | } 22 | 23 | return template; 24 | } 25 | } 26 | 27 | export default UpdateTemplateService; 28 | -------------------------------------------------------------------------------- /packages/server/src/shared/infra/http/middlewares/ensureAuthenticated.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | 3 | import jwt from 'jsonwebtoken'; 4 | 5 | import authConfig from '@config/auth'; 6 | 7 | import TokenExpiredError from '@shared/errors/TokenExpiredError'; 8 | 9 | export default function ensureAuthenticated( 10 | req: Request, 11 | res: Response, 12 | next: NextFunction, 13 | ): void | Error { 14 | const authHeader = req.headers.authorization; 15 | 16 | if (!authHeader) { 17 | throw new Error('JWT token is missing.'); 18 | } 19 | 20 | const [, token] = authHeader.split(' '); 21 | 22 | try { 23 | jwt.verify(token, authConfig.secret); 24 | 25 | return next(); 26 | } catch { 27 | throw new TokenExpiredError('Invalid JWT token'); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/server/src/modules/senders/infra/mongoose/schemas/Sender.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document, Schema, Model } from 'mongoose'; 2 | 3 | export type SenderAttributes = { 4 | name: string; 5 | email: string; 6 | }; 7 | 8 | export type SenderDocument = Document & SenderAttributes; 9 | 10 | type SenderModel = Model; 11 | 12 | const SenderSchema = new Schema( 13 | { 14 | name: { 15 | type: String, 16 | trim: true, 17 | required: true, 18 | }, 19 | email: { 20 | type: String, 21 | lowercase: true, 22 | trim: true, 23 | unique: true, 24 | required: true, 25 | }, 26 | }, 27 | { 28 | timestamps: true, 29 | }, 30 | ); 31 | 32 | export default mongoose.model( 33 | 'Sender', 34 | SenderSchema, 35 | ); 36 | -------------------------------------------------------------------------------- /packages/server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12-alpine AS builder 2 | 3 | RUN mkdir -p /home/node/build/node_modules && chown -R node:node /home/node/build 4 | 5 | WORKDIR /home/node/build 6 | 7 | COPY --chown=node:node package.json yarn.* ./ 8 | 9 | USER node 10 | 11 | RUN yarn 12 | 13 | COPY --chown=node:node . . 14 | 15 | RUN yarn build 16 | 17 | 18 | FROM node:12-alpine 19 | 20 | RUN mkdir -p /home/node/api/node_modules && chown -R node:node /home/node/api 21 | 22 | WORKDIR /home/node/api 23 | 24 | RUN yarn global add pm2 25 | 26 | USER node 27 | 28 | COPY --chown=node:node --from=builder /home/node/build/node_modules ./node_modules 29 | COPY --chown=node:node --from=builder /home/node/build/process.yml . 30 | COPY --chown=node:node --from=builder /home/node/build/dist . 31 | 32 | EXPOSE 3333 33 | 34 | CMD ["pm2-runtime", "process.yml"] -------------------------------------------------------------------------------- /packages/server/src/modules/contacts/services/ChangeContactSubscriptionStatusService.ts: -------------------------------------------------------------------------------- 1 | import Contact, { 2 | ContactDocument, 3 | } from '@modules/contacts/infra/mongoose/schemas/Contact'; 4 | 5 | import Service from '@shared/core/Service'; 6 | 7 | interface Request { 8 | contact_id: string; 9 | subscribed: boolean; 10 | } 11 | 12 | class ChangeContactSubscriptionStatusService 13 | implements Service { 14 | async execute({ contact_id, subscribed }: Request): Promise { 15 | const contact = await Contact.findById(contact_id); 16 | 17 | if (!contact) { 18 | throw new Error('Contact not found.'); 19 | } 20 | 21 | await contact.updateOne({ 22 | subscribed, 23 | }); 24 | 25 | return contact; 26 | } 27 | } 28 | 29 | export default ChangeContactSubscriptionStatusService; 30 | -------------------------------------------------------------------------------- /packages/server/src/shared/infra/http/api/v1.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | 3 | import contactRouter from '@modules/contacts/infra/http/routes/contact'; 4 | import tagRouter from '@modules/contacts/infra/http/routes/tag'; 5 | import messageRouter from '@modules/messages/infra/http/routes/message'; 6 | import templateRouter from '@modules/messages/infra/http/routes/template'; 7 | import senderRouter from '@modules/senders/infra/http/routes/sender'; 8 | import sessionRouter from '@modules/users/infra/http/routes/session'; 9 | 10 | const v1Router = Router(); 11 | 12 | v1Router.use('/contacts', contactRouter); 13 | v1Router.use('/tags', tagRouter); 14 | v1Router.use('/messages', messageRouter); 15 | v1Router.use('/templates', templateRouter); 16 | v1Router.use('/sessions', sessionRouter); 17 | v1Router.use('/senders', senderRouter); 18 | 19 | export default v1Router; 20 | -------------------------------------------------------------------------------- /packages/web/src/components/Sidebar/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NavLink } from 'react-router-dom'; 3 | 4 | import { MdAccountCircle } from 'react-icons/md'; 5 | import { Container, Nav, Profile } from './styles'; 6 | import logo from '../../assets/logo-u.svg'; 7 | 8 | const Sidebar: React.FC = () => { 9 | return ( 10 | 11 | Umbriel 12 | 13 | 19 | 20 | 21 | 22 | Rocketseat 23 | 24 | 25 | ); 26 | }; 27 | 28 | export default Sidebar; 29 | -------------------------------------------------------------------------------- /packages/server/src/modules/users/infra/mongoose/schemas/User.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document, Schema, Model } from 'mongoose'; 2 | 3 | export type UserAttributes = { 4 | name: string; 5 | email: string; 6 | password: string; 7 | }; 8 | 9 | export type UserDocument = Document & UserAttributes; 10 | 11 | type UserModel = Model; 12 | 13 | const UserSchema = new Schema( 14 | { 15 | name: { 16 | type: String, 17 | trim: true, 18 | required: true, 19 | }, 20 | email: { 21 | type: String, 22 | lowercase: true, 23 | trim: true, 24 | unique: true, 25 | required: true, 26 | }, 27 | password: { 28 | type: String, 29 | required: true, 30 | }, 31 | }, 32 | { 33 | timestamps: true, 34 | }, 35 | ); 36 | 37 | export default mongoose.model('User', UserSchema); 38 | -------------------------------------------------------------------------------- /packages/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "commonjs", 5 | "allowJs": true, 6 | "outDir": "./dist", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "strictPropertyInitialization": false, 10 | "moduleResolution": "node", 11 | "baseUrl": "./src", 12 | "allowSyntheticDefaultImports": true, 13 | "esModuleInterop": true, 14 | "experimentalDecorators": true, 15 | "emitDecoratorMetadata": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "noImplicitAny": true, 18 | "skipLibCheck": true, 19 | "typeRoots": [ 20 | "../../node_modules/@types", 21 | "src/@types" 22 | ], 23 | "paths": { 24 | "@config/*": ["config/*"], 25 | "@modules/*": ["modules/*"], 26 | "@shared/*": ["shared/*"] 27 | } 28 | }, 29 | "include": ["src", "__tests__"] 30 | } 31 | -------------------------------------------------------------------------------- /packages/web/src/pages/Contacts/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.main` 4 | width: 100%; 5 | margin: 0 auto; 6 | padding: 30px; 7 | 8 | > header { 9 | display: flex; 10 | align-items: center; 11 | margin-bottom: 15px; 12 | 13 | form { 14 | display: flex; 15 | flex-direction: row; 16 | margin-left: auto; 17 | margin-right: 20px; 18 | 19 | button svg { 20 | margin: 0; 21 | } 22 | 23 | input { 24 | margin-right: 5px; 25 | background: #15121e; 26 | border: 2px solid #15121e; 27 | color: #e1e1e6; 28 | border-radius: 4px; 29 | padding: 5px 12px; 30 | transition: border-color 0.2s; 31 | font-size: 14px; 32 | 33 | &:focus { 34 | border-color: #7159c1; 35 | } 36 | } 37 | } 38 | } 39 | `; 40 | -------------------------------------------------------------------------------- /packages/server/src/modules/users/infra/http/routes/session.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import { container } from 'tsyringe'; 4 | import * as Yup from 'yup'; 5 | 6 | import AuthenticateService from '@modules/users/services/AuthenticateService'; 7 | 8 | const sessionRouter = express.Router(); 9 | 10 | sessionRouter.post('/', async (req, res) => { 11 | const schema = Yup.object().shape({ 12 | email: Yup.string().email().required(), 13 | password: Yup.string().required(), 14 | }); 15 | 16 | if (!(await schema.isValid(req.body))) { 17 | return res.status(400).json({ error: 'Validation fails' }); 18 | } 19 | 20 | const { email, password } = req.body; 21 | 22 | const authService = container.resolve(AuthenticateService); 23 | 24 | const result = await authService.execute({ email, password }); 25 | 26 | return res.json(result); 27 | }); 28 | 29 | export default sessionRouter; 30 | -------------------------------------------------------------------------------- /packages/server/src/shared/adapters/implementations/queue/BullProvider.ts: -------------------------------------------------------------------------------- 1 | import Bull, { Queue, QueueOptions, ProcessPromiseFunction } from 'bull'; 2 | 3 | import QueueProvider from '../../models/QueueProvider'; 4 | 5 | class BullProvider implements QueueProvider { 6 | private queue: Queue; 7 | 8 | constructor(queueConfig: QueueOptions) { 9 | this.queue = new Bull('mail-queue', queueConfig); 10 | } 11 | 12 | async add(data: object | object[]): Promise { 13 | if (Array.isArray(data)) { 14 | const parsedJobs = data.map(jobData => { 15 | return { data: jobData }; 16 | }); 17 | 18 | await this.queue.addBulk(parsedJobs); 19 | 20 | return; 21 | } 22 | 23 | await this.queue.add(data); 24 | } 25 | 26 | process(processFunction: ProcessPromiseFunction): void { 27 | this.queue.process(150, processFunction); 28 | } 29 | } 30 | 31 | export default BullProvider; 32 | -------------------------------------------------------------------------------- /packages/server/src/modules/senders/services/SearchSendersService.ts: -------------------------------------------------------------------------------- 1 | import Sender, { 2 | SenderDocument, 3 | } from '@modules/senders/infra/mongoose/schemas/Sender'; 4 | 5 | import Service from '@shared/core/Service'; 6 | 7 | interface Request { 8 | search: string; 9 | page: number; 10 | per_page?: number; 11 | } 12 | 13 | type Response = { 14 | senders: SenderDocument[]; 15 | totalCount: number; 16 | }; 17 | 18 | class SearchSendersService implements Service { 19 | async execute({ search, page, per_page = 20 }: Request): Promise { 20 | const senders = await Sender.find({ 21 | name: new RegExp(`${search.toLowerCase()}`, 'i'), 22 | }) 23 | .limit(per_page) 24 | .skip(per_page * (page - 1)); 25 | 26 | const totalCount = await Sender.estimatedDocumentCount(); 27 | 28 | return { senders, totalCount }; 29 | } 30 | } 31 | 32 | export default SearchSendersService; 33 | -------------------------------------------------------------------------------- /packages/server/src/modules/contacts/services/ChangeTagTitleService.ts: -------------------------------------------------------------------------------- 1 | import Tag from '@modules/contacts/infra/mongoose/schemas/Tag'; 2 | 3 | import Service from '@shared/core/Service'; 4 | 5 | interface Request { 6 | integrationTeamId?: string; 7 | title: string; 8 | } 9 | 10 | class ChangeTagTitleService implements Service { 11 | async execute({ integrationTeamId, title }: Request): Promise { 12 | const existentTeam = await Tag.findOne({ 13 | integrationId: integrationTeamId, 14 | }); 15 | 16 | if (!existentTeam) { 17 | throw new Error('Team not found'); 18 | } 19 | 20 | try { 21 | await Tag.findOneAndUpdate( 22 | { integrationId: integrationTeamId }, 23 | { title }, 24 | ); 25 | } catch (err) { 26 | throw new Error('Unable to update the title, please try again later.'); 27 | } 28 | } 29 | } 30 | 31 | export default ChangeTagTitleService; 32 | -------------------------------------------------------------------------------- /packages/server/src/shared/adapters/implementations/mail/MailtrapProvider.ts: -------------------------------------------------------------------------------- 1 | import nodemailer, { Transporter } from 'nodemailer'; 2 | 3 | import MailProvider from '../../models/MailProvider'; 4 | 5 | interface Message { 6 | from: { 7 | name: string; 8 | email: string; 9 | }; 10 | to: string; 11 | subject: string; 12 | body: string; 13 | } 14 | 15 | class MailtrapProvider implements MailProvider { 16 | private transporter: Transporter; 17 | 18 | constructor(mailConfig: object) { 19 | this.transporter = nodemailer.createTransport(mailConfig); 20 | } 21 | 22 | async sendEmail(message: Message): Promise { 23 | await this.transporter.sendMail({ 24 | from: { 25 | name: message.from.name, 26 | address: message.from.email, 27 | }, 28 | to: message.to, 29 | subject: message.subject, 30 | html: message.body, 31 | }); 32 | } 33 | } 34 | 35 | export default MailtrapProvider; 36 | -------------------------------------------------------------------------------- /packages/server/src/modules/messages/services/SearchTemplatesService.ts: -------------------------------------------------------------------------------- 1 | import Template, { 2 | TemplateDocument, 3 | } from '@modules/messages/infra/mongoose/schemas/Template'; 4 | 5 | import Service from '@shared/core/Service'; 6 | 7 | interface Request { 8 | search: string; 9 | page: number; 10 | per_page?: number; 11 | } 12 | 13 | type Response = { 14 | templates: TemplateDocument[]; 15 | totalCount: number; 16 | }; 17 | 18 | class SearchTemplatesService implements Service { 19 | async execute({ search, page, per_page = 20 }: Request): Promise { 20 | const templates = await Template.find({ 21 | title: new RegExp(`${search.toLowerCase()}`, 'i'), 22 | }) 23 | .limit(per_page) 24 | .skip(per_page * (page - 1)); 25 | 26 | const totalCount = await Template.estimatedDocumentCount(); 27 | 28 | return { templates, totalCount }; 29 | } 30 | } 31 | 32 | export default SearchTemplatesService; 33 | -------------------------------------------------------------------------------- /packages/server/src/modules/senders/services/UpdateSenderService.ts: -------------------------------------------------------------------------------- 1 | import Sender, { 2 | SenderAttributes, 3 | SenderDocument, 4 | } from '@modules/senders/infra/mongoose/schemas/Sender'; 5 | 6 | import Service from '@shared/core/Service'; 7 | 8 | interface Request { 9 | id: string; 10 | data: SenderAttributes; 11 | } 12 | 13 | type Response = SenderDocument; 14 | 15 | class UpdateSenderService implements Service { 16 | async execute({ id, data }: Request): Promise { 17 | const duplicatedSender = await Sender.findOne({ 18 | email: data.email, 19 | _id: { $ne: id }, 20 | }); 21 | 22 | if (duplicatedSender) { 23 | throw new Error('Duplicated sender'); 24 | } 25 | 26 | const sender = await Sender.findByIdAndUpdate(id, data, { new: true }); 27 | 28 | if (!sender) { 29 | throw new Error('Sender not found'); 30 | } 31 | 32 | return sender; 33 | } 34 | } 35 | 36 | export default UpdateSenderService; 37 | -------------------------------------------------------------------------------- /packages/server/src/modules/contacts/services/ChangeContactEmailService.ts: -------------------------------------------------------------------------------- 1 | import Contact from '@modules/contacts/infra/mongoose/schemas/Contact'; 2 | 3 | import Service from '@shared/core/Service'; 4 | 5 | interface Request { 6 | integrationUserId: string; 7 | email: string; 8 | } 9 | 10 | class ChangeContactEmailService implements Service { 11 | async execute({ integrationUserId, email }: Request): Promise { 12 | const existentUser = await Contact.findOne({ 13 | integrationId: integrationUserId, 14 | }); 15 | 16 | if (!existentUser) { 17 | throw new Error('User not found'); 18 | } 19 | 20 | try { 21 | await Contact.findOneAndUpdate( 22 | { integrationId: integrationUserId }, 23 | { email }, 24 | ); 25 | } catch (err) { 26 | throw new Error( 27 | 'It was not possible to update the email, please try again later.', 28 | ); 29 | } 30 | } 31 | } 32 | 33 | export default ChangeContactEmailService; 34 | -------------------------------------------------------------------------------- /packages/server/src/modules/senders/services/DeleteSenderService.ts: -------------------------------------------------------------------------------- 1 | import Message from '@modules/messages/infra/mongoose/schemas/Message'; 2 | import Sender from '@modules/senders/infra/mongoose/schemas/Sender'; 3 | 4 | import Service from '@shared/core/Service'; 5 | 6 | type Request = string; 7 | type Response = void; 8 | 9 | class DeleteSenderService implements Service { 10 | async execute(id: Request): Promise { 11 | const sender = await Sender.findById(id); 12 | 13 | if (!sender) { 14 | throw new Error('Sender not found'); 15 | } 16 | 17 | const hasMessages = await Message.findOne({ 18 | sender: { 19 | email: { 20 | $eq: sender.email, 21 | }, 22 | }, 23 | }); 24 | 25 | if (hasMessages) { 26 | throw new Error( 27 | "You can't delete a sender that has already sent messages", 28 | ); 29 | } 30 | 31 | await sender.remove(); 32 | } 33 | } 34 | 35 | export default DeleteSenderService; 36 | -------------------------------------------------------------------------------- /packages/server/src/modules/messages/services/SearchMessagesService.ts: -------------------------------------------------------------------------------- 1 | import Message, { 2 | MessageDocument, 3 | } from '@modules/messages/infra/mongoose/schemas/Message'; 4 | 5 | import Service from '@shared/core/Service'; 6 | 7 | interface Request { 8 | search: string; 9 | page: number; 10 | per_page?: number; 11 | } 12 | 13 | type Response = { 14 | messages: MessageDocument[]; 15 | totalCount: number; 16 | }; 17 | 18 | class SearchMessagesService implements Service { 19 | async execute({ search, page, per_page = 20 }: Request): Promise { 20 | const messages = await Message.find({ 21 | subject: new RegExp(`${search.toLowerCase()}`, 'i'), 22 | }) 23 | .sort({ createdAt: -1 }) 24 | .populate('tags') 25 | .limit(per_page) 26 | .skip(per_page * (page - 1)); 27 | 28 | const totalCount = await Message.estimatedDocumentCount(); 29 | 30 | return { messages, totalCount }; 31 | } 32 | } 33 | 34 | export default SearchMessagesService; 35 | -------------------------------------------------------------------------------- /packages/server/src/modules/contacts/services/SearchContactsService.ts: -------------------------------------------------------------------------------- 1 | import Contact, { 2 | ContactDocument, 3 | } from '@modules/contacts/infra/mongoose/schemas/Contact'; 4 | 5 | import Service from '@shared/core/Service'; 6 | 7 | interface Request { 8 | search: string; 9 | page: number; 10 | per_page?: number; 11 | } 12 | 13 | interface Response { 14 | contacts: ContactDocument[]; 15 | totalCount: number; 16 | } 17 | 18 | class SearchContactsService implements Service { 19 | async execute({ search, page, per_page = 20 }: Request): Promise { 20 | const contacts = await Contact.find() 21 | .where({ 22 | email: { 23 | $regex: search, 24 | $options: 'i', 25 | }, 26 | }) 27 | .populate('tags') 28 | .limit(per_page) 29 | .skip(per_page * (page - 1)); 30 | 31 | const totalCount = await Contact.estimatedDocumentCount(); 32 | 33 | return { contacts, totalCount }; 34 | } 35 | } 36 | 37 | export default SearchContactsService; 38 | -------------------------------------------------------------------------------- /packages/server/src/config/logger.ts: -------------------------------------------------------------------------------- 1 | import { LoggerOptions, format, transports } from 'winston'; 2 | import { MongoDB } from 'winston-mongodb'; 3 | 4 | import mongoConfig from '@config/mongo'; 5 | 6 | const mongoUserPass = mongoConfig.username 7 | ? `${mongoConfig.username}:${mongoConfig.password}@` 8 | : ''; 9 | 10 | interface LoggerConfig { 11 | driver: 'winston'; 12 | 13 | config: { 14 | winston: LoggerOptions; 15 | }; 16 | } 17 | 18 | export default { 19 | driver: 'winston', 20 | 21 | config: { 22 | winston: { 23 | format: format.combine(format.colorize(), format.simple()), 24 | transports: [ 25 | new transports.Console({ 26 | level: 'info', 27 | }), 28 | new MongoDB({ 29 | level: 'warn', 30 | db: `mongodb://${mongoUserPass}${mongoConfig.host}:${mongoConfig.port}/${mongoConfig.database}`, 31 | collection: 'logs', 32 | options: { 33 | useUnifiedTopology: true, 34 | }, 35 | }), 36 | ], 37 | }, 38 | }, 39 | } as LoggerConfig; 40 | -------------------------------------------------------------------------------- /packages/server/src/config/mail.ts: -------------------------------------------------------------------------------- 1 | import { QueueOptions } from 'bull'; 2 | import SMTPTransport from 'nodemailer/lib/smtp-transport'; 3 | 4 | interface MailConfig { 5 | driver: 'ses' | 'mailtrap' | 'fake'; 6 | 7 | queue: QueueOptions; 8 | 9 | config: { 10 | mailtrap: SMTPTransport.Options; 11 | ses: object; 12 | fake: object; 13 | }; 14 | } 15 | 16 | export default { 17 | driver: process.env.MAIL_DRIVER || 'ses', 18 | 19 | queue: { 20 | defaultJobOptions: { 21 | removeOnComplete: true, 22 | attempts: 5, 23 | backoff: { 24 | type: 'exponential', 25 | delay: 5000, 26 | }, 27 | }, 28 | limiter: { 29 | max: 150, 30 | duration: 1000, 31 | }, 32 | }, 33 | 34 | config: { 35 | mailtrap: { 36 | host: 'smtp.mailtrap.io', 37 | port: 2525, 38 | ssl: false, 39 | tls: true, 40 | auth: { 41 | user: process.env.MAILTRAP_USER, 42 | pass: process.env.MAILTRAP_PASS, 43 | }, 44 | }, 45 | 46 | ses: {}, 47 | 48 | fake: {}, 49 | }, 50 | } as MailConfig; 51 | -------------------------------------------------------------------------------- /packages/server/src/shared/adapters/index.ts: -------------------------------------------------------------------------------- 1 | import { container } from 'tsyringe'; 2 | 3 | import loggerConfig from '@config/logger'; 4 | import mailConfig from '@config/mail'; 5 | import queueConfig from '@config/queue'; 6 | import redisConfig from '@config/redis'; 7 | 8 | import LoggerProvider from './models/LoggerProvider'; 9 | import MailProvider from './models/MailProvider'; 10 | import QueueProvider from './models/QueueProvider'; 11 | import providers from './providers'; 12 | 13 | const Mail = providers.mail[mailConfig.driver]; 14 | const Queue = providers.queue[queueConfig.driver]; 15 | const Logger = providers.logger[loggerConfig.driver]; 16 | 17 | container.registerInstance( 18 | 'MailProvider', 19 | new Mail(mailConfig.config[mailConfig.driver]), 20 | ); 21 | 22 | container.registerInstance( 23 | 'QueueProvider', 24 | new Queue({ 25 | ...mailConfig.queue, 26 | redis: redisConfig, 27 | }), 28 | ); 29 | 30 | container.registerInstance( 31 | 'LoggerProvider', 32 | new Logger(loggerConfig.config[loggerConfig.driver]), 33 | ); 34 | -------------------------------------------------------------------------------- /packages/web/src/components/Form/Input/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.div` 4 | margin-bottom: 15px; 5 | 6 | label { 7 | display: block; 8 | margin-bottom: 8px; 9 | font-weight: bold; 10 | font-size: 16px; 11 | } 12 | 13 | > small { 14 | font-size: 13px; 15 | display: block; 16 | margin-bottom: 11px; 17 | opacity: 0.6; 18 | } 19 | 20 | > input, 21 | > textarea { 22 | display: block; 23 | resize: vertical; 24 | margin-top: 3px; 25 | background: #15121e; 26 | border: 2px solid #15121e; 27 | color: #e1e1e6; 28 | border-radius: 4px; 29 | padding: 12px 15px; 30 | width: 100%; 31 | transition: border-color 0.2s; 32 | 33 | &:focus { 34 | border-color: #7159c1; 35 | } 36 | 37 | &[disabled] { 38 | cursor: not-allowed; 39 | background: #201b2d; 40 | } 41 | } 42 | 43 | > textarea { 44 | min-height: 200px; 45 | } 46 | 47 | > span { 48 | color: #ce4a4a; 49 | display: block; 50 | margin-top: 5px; 51 | } 52 | `; 53 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "jest": true, 6 | "es6": true 7 | }, 8 | "extends": [ 9 | "airbnb", 10 | "plugin:react/recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "prettier/@typescript-eslint", 13 | "plugin:prettier/recommended" 14 | ], 15 | "globals": { 16 | "Atomics": "readonly", 17 | "SharedArrayBuffer": "readonly" 18 | }, 19 | "parser": "@typescript-eslint/parser", 20 | "parserOptions": { 21 | "ecmaFeatures": { 22 | "jsx": true 23 | }, 24 | "ecmaVersion": 2018, 25 | "sourceType": "module" 26 | }, 27 | "plugins": [ 28 | "react", 29 | "@typescript-eslint", 30 | "react-hooks", 31 | "prettier" 32 | ], 33 | "rules": { 34 | "prettier/prettier": "error", 35 | "import/no-unresolved": "off", 36 | "import/extensions": [ 37 | "error", 38 | "ignorePackages", 39 | { 40 | "ts": "never", 41 | "tsx": "never" 42 | } 43 | ] 44 | }, 45 | "settings": { 46 | "import/resolver": { 47 | "typescript": {} 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/web/src/pages/MessageList/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | interface ProgressProps { 4 | total: number; 5 | current: number; 6 | } 7 | 8 | export const Container = styled.main` 9 | width: 100%; 10 | margin: 0 auto; 11 | padding: 30px; 12 | 13 | > header { 14 | display: flex; 15 | justify-content: space-between; 16 | align-items: center; 17 | margin-bottom: 15px; 18 | } 19 | `; 20 | 21 | export const ProgressBar = styled.div` 22 | height: 20px; 23 | width: 100%; 24 | border-radius: 4px; 25 | background: #999; 26 | position: relative; 27 | 28 | display: flex; 29 | align-items: center; 30 | justify-content: center; 31 | 32 | span { 33 | font-size: 13px; 34 | line-height: 21px; 35 | z-index: 5; 36 | color: #fff; 37 | font-weight: bold; 38 | } 39 | 40 | &::before { 41 | content: ''; 42 | width: ${props => (props.current * 100) / props.total}%; 43 | max-width: 100%; 44 | height: 100%; 45 | border-radius: 4px; 46 | position: absolute; 47 | left: 0; 48 | top: 0; 49 | background: #ff79c6; 50 | } 51 | `; 52 | -------------------------------------------------------------------------------- /packages/server/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Rocketseat 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/web/src/routes/Route.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Redirect, 4 | Route, 5 | RouteProps, 6 | RouteComponentProps, 7 | } from 'react-router-dom'; 8 | 9 | import AuthLayout from '../pages/_layouts/auth'; 10 | import DefaultLayout from '../pages/_layouts/default'; 11 | 12 | interface Props extends RouteProps { 13 | isPrivate?: boolean; 14 | } 15 | 16 | const RouteWrapper: React.FC = ({ 17 | component: Component, 18 | isPrivate = false, 19 | ...rest 20 | }) => { 21 | const authenticated = !!localStorage.getItem('@Umbriel:token'); 22 | 23 | if (!authenticated && isPrivate) { 24 | return ; 25 | } 26 | 27 | if (authenticated && !isPrivate) { 28 | return ; 29 | } 30 | 31 | const Layout = authenticated ? DefaultLayout : AuthLayout; 32 | 33 | if (!Component) { 34 | return null; 35 | } 36 | 37 | return ( 38 | ) => ( 41 | 42 | 43 | 44 | )} 45 | /> 46 | ); 47 | }; 48 | 49 | export default RouteWrapper; 50 | -------------------------------------------------------------------------------- /packages/server/src/modules/contacts/services/ImportContactsService.ts: -------------------------------------------------------------------------------- 1 | import csvParse from 'csv-parse'; 2 | import { Readable } from 'stream'; 3 | import { container } from 'tsyringe'; 4 | 5 | import Service from '@shared/core/Service'; 6 | 7 | import CreateContactService from './CreateContactService'; 8 | 9 | interface Request { 10 | contactsFileStream: Readable; 11 | tags: string[]; 12 | } 13 | 14 | class ImportContactsService implements Service { 15 | async execute({ contactsFileStream, tags }: Request): Promise { 16 | const parsers = csvParse({ 17 | delimiter: ';', 18 | }); 19 | 20 | const parseCSV = contactsFileStream.pipe(parsers); 21 | const createContact = container.resolve(CreateContactService); 22 | 23 | const teams = tags.map(tag => ({ title: tag })); 24 | 25 | parseCSV.on('data', async line => { 26 | const [email] = line; 27 | 28 | if (!email) return; 29 | 30 | await createContact.execute({ 31 | email, 32 | teams, 33 | }); 34 | }); 35 | 36 | await new Promise(resolve => parseCSV.on('end', resolve)); 37 | } 38 | } 39 | 40 | export default ImportContactsService; 41 | -------------------------------------------------------------------------------- /packages/server/src/modules/users/services/AuthenticateService.spec.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcryptjs'; 2 | import { container } from 'tsyringe'; 3 | 4 | import User from '@modules/users/infra/mongoose/schemas/User'; 5 | import AuthenticateService from '@modules/users/services/AuthenticateService'; 6 | 7 | import MongoMock from '@shared/tests/MongoMock'; 8 | 9 | describe('Add Recipients to Queue', () => { 10 | beforeAll(async () => { 11 | await MongoMock.connect(); 12 | }); 13 | 14 | afterAll(async () => { 15 | await MongoMock.disconnect(); 16 | }); 17 | 18 | beforeEach(async () => { 19 | await User.deleteMany({}); 20 | }); 21 | 22 | it('should be able to authenticate', async () => { 23 | await User.create({ 24 | name: 'Test', 25 | email: 'test@example.com', 26 | password: bcrypt.hashSync('testpass', 4), 27 | }); 28 | 29 | const authenticate = container.resolve(AuthenticateService); 30 | 31 | const result = await authenticate.execute({ 32 | email: 'test@example.com', 33 | password: 'testpass', 34 | }); 35 | 36 | expect(result).toHaveProperty('token'); 37 | expect(result).toHaveProperty('user'); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /packages/web/src/pages/MessageDetail/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.main` 4 | width: 100%; 5 | padding: 30px; 6 | 7 | > header { 8 | display: flex; 9 | margin-bottom: 15px; 10 | 11 | > h1 { 12 | margin-right: auto; 13 | } 14 | 15 | button { 16 | margin-left: 10px; 17 | } 18 | } 19 | 20 | > div { 21 | > strong { 22 | display: block; 23 | font-size: 14px; 24 | opacity: 0.7; 25 | margin-bottom: 5px; 26 | 27 | &:not(:first-child) { 28 | margin-top: 15px; 29 | padding-top: 15px; 30 | border-top: 1px solid #252131; 31 | } 32 | } 33 | } 34 | `; 35 | 36 | export const MessageContent = styled.div` 37 | background: #fff; 38 | color: #222; 39 | padding: 12px 15px; 40 | width: 100%; 41 | margin-top: 20px; 42 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, 43 | Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 44 | -webkit-font-smoothing: initial; 45 | border: 1px solid #eee; 46 | border-radius: 4px; 47 | 48 | h1, 49 | h2, 50 | h3, 51 | h4, 52 | h5 { 53 | color: inherit; 54 | } 55 | `; 56 | -------------------------------------------------------------------------------- /packages/server/src/modules/contacts/infra/http/routes/tag.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import { container } from 'tsyringe'; 4 | 5 | import GetRecipientsFromTags from '@modules/contacts/services/GetRecipientsFromTags'; 6 | import SearchTagsService from '@modules/contacts/services/SearchTagsService'; 7 | 8 | import ensureAuthenticated from '@shared/infra/http/middlewares/ensureAuthenticated'; 9 | 10 | const tagRouter = express.Router(); 11 | 12 | tagRouter.use(ensureAuthenticated); 13 | 14 | tagRouter.get('/', async (req, res) => { 15 | const { search } = req.query; 16 | 17 | const searchTags = container.resolve(SearchTagsService); 18 | 19 | const tags = await searchTags.execute({ search: String(search) }); 20 | 21 | return res.json(tags); 22 | }); 23 | 24 | tagRouter.get('/recipients', async (req, res) => { 25 | const { tags } = req.query; 26 | 27 | const tagsArray = String(tags) 28 | .split(',') 29 | .map((tag: string) => tag.trim()); 30 | 31 | const getRecipientsFromTags = new GetRecipientsFromTags(); 32 | 33 | const recipients = await getRecipientsFromTags.execute({ tags: tagsArray }); 34 | 35 | return res.json({ recipients: recipients.length }); 36 | }); 37 | 38 | export default tagRouter; 39 | -------------------------------------------------------------------------------- /packages/server/src/modules/users/services/AuthenticateService.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcryptjs'; 2 | import jwt from 'jsonwebtoken'; 3 | import { injectable } from 'tsyringe'; 4 | 5 | import User, { UserDocument } from '@modules/users/infra/mongoose/schemas/User'; 6 | 7 | import authConfig from '@config/auth'; 8 | 9 | import Service from '@shared/core/Service'; 10 | 11 | interface Request { 12 | email: string; 13 | password: string; 14 | } 15 | 16 | interface Response { 17 | token: string; 18 | user: UserDocument; 19 | } 20 | 21 | @injectable() 22 | class AuthenticateService implements Service { 23 | async execute({ email, password }: Request): Promise { 24 | const user = await User.findOne({ 25 | email, 26 | }); 27 | 28 | if (!user) { 29 | throw new Error('User not found'); 30 | } 31 | 32 | const passwordValid = await bcrypt.compare(password, user.password); 33 | 34 | if (!passwordValid) { 35 | throw new Error('Password does not match'); 36 | } 37 | 38 | return { 39 | token: jwt.sign({ id: user.id }, authConfig.secret, { 40 | expiresIn: authConfig.expiresIn, 41 | }), 42 | user, 43 | }; 44 | } 45 | } 46 | 47 | export default AuthenticateService; 48 | -------------------------------------------------------------------------------- /packages/server/src/modules/contacts/services/ChangeContactSubscriptionStatusService.spec.ts: -------------------------------------------------------------------------------- 1 | import Contact from '@modules/contacts/infra/mongoose/schemas/Contact'; 2 | import Tag from '@modules/contacts/infra/mongoose/schemas/Tag'; 3 | 4 | import MongoMock from '@shared/tests/MongoMock'; 5 | 6 | import ChangeContactSubscriptionStatusService from './ChangeContactSubscriptionStatusService'; 7 | 8 | describe('Change Contact Subscription Status', () => { 9 | beforeAll(async () => { 10 | await MongoMock.connect(); 11 | }); 12 | 13 | afterAll(async () => { 14 | await MongoMock.disconnect(); 15 | }); 16 | 17 | beforeEach(async () => { 18 | await Tag.deleteMany({}); 19 | await Contact.deleteMany({}); 20 | }); 21 | 22 | it('should be able to import new contacts', async () => { 23 | const contact = await Contact.create({ 24 | email: 'johndoe@example.com', 25 | }); 26 | 27 | const changeContactSubscriptionStatus = new ChangeContactSubscriptionStatusService(); 28 | 29 | await changeContactSubscriptionStatus.execute({ 30 | contact_id: contact._id, 31 | subscribed: false, 32 | }); 33 | 34 | const updatedContact = await Contact.findById(contact._id); 35 | 36 | expect(updatedContact?.subscribed).toBe(false); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /packages/web/src/components/PaginatedTable/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const DataTable = styled.table` 4 | width: 100%; 5 | 6 | th { 7 | padding: 12px; 8 | text-align: left; 9 | } 10 | 11 | tr { 12 | td { 13 | vertical-align: middle; 14 | padding: 12px; 15 | border-top: 1px solid #252131; 16 | 17 | h3 { 18 | margin: 0; 19 | font-weight: 500; 20 | } 21 | 22 | span { 23 | margin-right: 5px; 24 | } 25 | 26 | &:last-child button { 27 | margin-left: 8px; 28 | } 29 | } 30 | 31 | &.no-border td { 32 | border: 0; 33 | padding-top: 0; 34 | } 35 | } 36 | `; 37 | 38 | export const Loading = styled.p` 39 | text-align: center; 40 | margin: 30px 0; 41 | `; 42 | 43 | export const Pagination = styled.footer` 44 | display: flex; 45 | flex-direction: row; 46 | justify-content: space-between; 47 | align-items: center; 48 | padding: 12px; 49 | 50 | > p { 51 | opacity: 0.8; 52 | 53 | span + span { 54 | border-left: 1px solid #252131; 55 | padding-left: 10px; 56 | margin-left: 10px; 57 | } 58 | } 59 | 60 | nav { 61 | display: flex; 62 | 63 | button { 64 | margin-left: 5px; 65 | } 66 | } 67 | `; 68 | -------------------------------------------------------------------------------- /packages/server/src/modules/contacts/infra/mongoose/schemas/Contact.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document, Schema, Model } from 'mongoose'; 2 | 3 | export type ContactDocument = Document & { 4 | email: string; 5 | subscribed: boolean; 6 | tags: string[]; 7 | }; 8 | 9 | type ContactModel = Model & { 10 | findByTags(tags: string[], additional?: object): Promise; 11 | }; 12 | 13 | const ContactSchema = new Schema( 14 | { 15 | name: String, 16 | email: { 17 | type: String, 18 | lowercase: true, 19 | trim: true, 20 | unique: true, 21 | required: true, 22 | }, 23 | subscribed: { 24 | type: Boolean, 25 | default: true, 26 | }, 27 | tags: [ 28 | { 29 | type: Schema.Types.ObjectId, 30 | ref: 'Tag', 31 | }, 32 | ], 33 | integrationId: String, 34 | }, 35 | { 36 | timestamps: true, 37 | }, 38 | ); 39 | 40 | ContactSchema.statics.findByTags = function findByTags( 41 | tags: string[], 42 | additional?: object, 43 | ): Promise { 44 | return this.find({ 45 | tags: { 46 | $in: tags, 47 | }, 48 | ...additional, 49 | }); 50 | }; 51 | 52 | export default mongoose.model( 53 | 'Contact', 54 | ContactSchema, 55 | ); 56 | -------------------------------------------------------------------------------- /packages/server/src/modules/contacts/services/RemoveContactFromTag.ts: -------------------------------------------------------------------------------- 1 | import Contact from '@modules/contacts/infra/mongoose/schemas/Contact'; 2 | import Tag from '@modules/contacts/infra/mongoose/schemas/Tag'; 3 | 4 | import Service from '@shared/core/Service'; 5 | 6 | interface Request { 7 | integrationUserId: string; 8 | integrationTeamId: string; 9 | } 10 | 11 | class RemoveContactTeamService implements Service { 12 | async execute({ 13 | integrationUserId, 14 | integrationTeamId, 15 | }: Request): Promise { 16 | const existentUser = await Contact.findOne({ 17 | integrationId: integrationUserId, 18 | }); 19 | const existentTeam = await Tag.findOne({ 20 | integrationId: integrationTeamId, 21 | }); 22 | 23 | if (!existentUser) { 24 | throw new Error('User not found'); 25 | } 26 | if (!existentTeam) { 27 | throw new Error('Team not found'); 28 | } 29 | 30 | try { 31 | existentUser.tags = existentUser.tags.filter( 32 | tag => String(tag) !== String(existentTeam._id), 33 | ); 34 | await existentUser.save(); 35 | } catch (err) { 36 | throw new Error( 37 | 'Unable to remove the user from the team, please try again later.', 38 | ); 39 | } 40 | } 41 | } 42 | 43 | export default RemoveContactTeamService; 44 | -------------------------------------------------------------------------------- /packages/server/src/modules/contacts/services/AddContactInTeamService.ts: -------------------------------------------------------------------------------- 1 | import Contact from '@modules/contacts/infra/mongoose/schemas/Contact'; 2 | import Tag from '@modules/contacts/infra/mongoose/schemas/Tag'; 3 | 4 | import Service from '@shared/core/Service'; 5 | 6 | interface Request { 7 | integrationUserId: string; 8 | integrationTeamId: string; 9 | } 10 | 11 | class AddContactInTeamService implements Service { 12 | async execute({ 13 | integrationUserId, 14 | integrationTeamId, 15 | }: Request): Promise { 16 | const existentUser = await Contact.findOne({ 17 | integrationId: integrationUserId, 18 | }); 19 | const existentTeam = await Tag.findOne({ 20 | integrationId: integrationTeamId, 21 | }); 22 | 23 | if (!existentUser) { 24 | throw new Error('User not found'); 25 | } 26 | if (!existentTeam) { 27 | throw new Error('Team not found'); 28 | } 29 | 30 | try { 31 | await Contact.findOneAndUpdate( 32 | { integrationId: integrationUserId }, 33 | { $addToSet: { tags: existentTeam._id } }, 34 | { upsert: true }, 35 | ); 36 | } catch (err) { 37 | throw new Error( 38 | 'Unable to remove the user from the team, please try again later.', 39 | ); 40 | } 41 | } 42 | } 43 | 44 | export default AddContactInTeamService; 45 | -------------------------------------------------------------------------------- /packages/server/src/shared/infra/http/app.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import 'dotenv/config'; 3 | 4 | import express from 'express'; 5 | 6 | import cors from 'cors'; 7 | import 'express-async-errors'; 8 | 9 | import * as Sentry from '@sentry/node'; 10 | 11 | import sentryConfig from '@config/sentry'; 12 | 13 | import '@shared/adapters'; 14 | import '@shared/infra/mongoose/connection'; 15 | import TokenExpiredError from '@shared/errors/TokenExpiredError'; 16 | 17 | import routes from './api/v1'; 18 | 19 | const app = express(); 20 | 21 | Sentry.init({ dsn: sentryConfig.dsn }); 22 | 23 | app.use(Sentry.Handlers.requestHandler()); 24 | 25 | app.use(express.json()); 26 | app.use( 27 | cors({ 28 | exposedHeaders: ['X-Total-Count', 'X-Total-Page'], 29 | }), 30 | ); 31 | app.use(routes); 32 | 33 | app.use(Sentry.Handlers.errorHandler()); 34 | 35 | app.use( 36 | ( 37 | err: Error, 38 | req: express.Request, 39 | res: express.Response, 40 | _: express.NextFunction, 41 | ) => { 42 | if (process.env.NODE_ENV !== 'production') { 43 | console.log(err.stack); 44 | } 45 | 46 | if (err instanceof TokenExpiredError) { 47 | return res.status(401).json({ 48 | code: 'token.expired', 49 | message: err.message, 50 | }); 51 | } 52 | 53 | return res.status(500).json({ error: 'Internal server error' }); 54 | }, 55 | ); 56 | 57 | export default app; 58 | -------------------------------------------------------------------------------- /packages/server/src/modules/messages/infra/mongoose/schemas/Recipient.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document, Schema, Model } from 'mongoose'; 2 | 3 | type MessageEvent = { 4 | type: string; 5 | eventAt: Date; 6 | data: object; 7 | }; 8 | 9 | export type RecipientAttributes = { 10 | recipientEmail: string; 11 | contact: string; 12 | message: string; 13 | events?: MessageEvent[]; 14 | }; 15 | 16 | export type RecipientDocument = Document & RecipientAttributes; 17 | 18 | type RecipientModel = Model; 19 | 20 | const RecipientSchema = new Schema( 21 | { 22 | recipientEmail: { 23 | type: String, 24 | required: true, 25 | }, 26 | contact: { 27 | type: Schema.Types.ObjectId, 28 | ref: 'Contact', 29 | }, 30 | message: { 31 | type: Schema.Types.ObjectId, 32 | ref: 'Message', 33 | }, 34 | events: [ 35 | { 36 | type: { 37 | type: String, 38 | required: true, 39 | enum: ['deliver', 'open', 'click', 'bounce', 'complaint', 'reject'], 40 | }, 41 | eventAt: { 42 | type: Date, 43 | required: true, 44 | default: Date.now, 45 | }, 46 | data: Schema.Types.Mixed, 47 | }, 48 | ], 49 | }, 50 | { 51 | timestamps: true, 52 | }, 53 | ); 54 | 55 | export default mongoose.model( 56 | 'Recipient', 57 | RecipientSchema, 58 | ); 59 | -------------------------------------------------------------------------------- /packages/web/src/services/usePaginatedRequest.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, useMemo } from 'react'; 2 | import { AxiosRequestConfig } from 'axios'; 3 | import useRequest, { Return } from './useRequest'; 4 | 5 | export interface PaginatedRequest extends Return { 6 | loadPrevious: () => void; 7 | loadNext: () => void; 8 | hasPreviousPage: boolean; 9 | hasNextPage: boolean; 10 | } 11 | 12 | export default function usePaginatedRequest( 13 | request: AxiosRequestConfig, 14 | ): PaginatedRequest { 15 | const [page, setPage] = useState(1); 16 | 17 | const { response, requestKey, ...rest } = useRequest({ 18 | ...request, 19 | params: { page, ...request.params }, 20 | }); 21 | 22 | const hasPreviousPage = useMemo(() => page > 1, [page]); 23 | const hasNextPage = useMemo(() => page < response?.headers['x-total-page'], [ 24 | page, 25 | response, 26 | ]); 27 | 28 | const loadPrevious = useCallback(() => { 29 | setPage(current => (hasPreviousPage ? current - 1 : current)); 30 | }, [hasPreviousPage]); 31 | 32 | const loadNext = useCallback(() => { 33 | setPage(current => (hasNextPage ? current + 1 : current)); 34 | }, [hasNextPage]); 35 | 36 | return { 37 | ...rest, 38 | requestKey, 39 | response, 40 | loadNext, 41 | loadPrevious, 42 | hasPreviousPage, 43 | hasNextPage, 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /packages/server/src/modules/messages/services/ParseTemplateService.ts: -------------------------------------------------------------------------------- 1 | import beautify from 'js-beautify'; 2 | 3 | import { TemplateDocument } from '@modules/messages/infra/mongoose/schemas/Template'; 4 | 5 | import Service from '@shared/core/Service'; 6 | 7 | interface Request { 8 | template: TemplateDocument; 9 | messageContent: string; 10 | } 11 | 12 | class ParseTemplateService implements Service { 13 | execute({ template, messageContent }: Request): string { 14 | const content = `

${messageContent 15 | .trim() 16 | .replace(/\n\n/g, '\n') 17 | .split('\n') 18 | .map(line => line.trim()) 19 | .join('

')}

`; 20 | 21 | const unsubscribeLink = `${process.env.APP_URL}/contacts/unsubscribe?contact={{ contact_id }}`; 22 | 23 | const unsubscribeBlock = [ 24 | '
', 25 | '

', 26 | 'não quer mais receber esses e-mails? 😢 ', 27 | `remover inscrição`, 28 | '

', 29 | ].join(''); 30 | 31 | const finalTemplate = template.content 32 | .replace('{{ message_content }}', content.trim()) 33 | .replace('{{ unsubscribe_link }}', unsubscribeBlock) 34 | .trim(); 35 | 36 | return beautify.html(finalTemplate, { 37 | indent_size: 2, 38 | }); 39 | } 40 | } 41 | 42 | export default ParseTemplateService; 43 | -------------------------------------------------------------------------------- /.github/workflows/server.yml: -------------------------------------------------------------------------------- 1 | name: Server 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | paths: 8 | - 'packages/server/**' 9 | - '!packages/server/.vscode/**' 10 | 11 | env: 12 | GCP_PROJECT: ${{ secrets.GCP_PROJECT }} 13 | IMAGE: umbriel 14 | REGISTRY_HOSTNAME: us.gcr.io 15 | 16 | jobs: 17 | deploy-server: 18 | 19 | name: Build & Publish 20 | runs-on: ubuntu-latest 21 | steps: 22 | 23 | - name: Checkout 24 | uses: actions/checkout@v2 25 | 26 | - name: Set tag 27 | run: echo ::set-env name=RELEASE_VERSION::${GITHUB_REF:10} 28 | 29 | - name: Setup gcloud 30 | uses: GoogleCloudPlatform/github-actions/setup-gcloud@master 31 | with: 32 | version: '290.0.1' 33 | project_id: ${{ secrets.GCP_PROJECT }} 34 | service_account_key: ${{ secrets.GCP_CONTAINER_PUSHER }} 35 | export_default_credentials: true 36 | 37 | - name: Configure docker client 38 | run: gcloud auth configure-docker 39 | 40 | - name: Build 41 | working-directory: packages/server 42 | run: | 43 | docker build -t "$REGISTRY_HOSTNAME"/"$GCP_PROJECT"/"$IMAGE":latest \ 44 | -t "$REGISTRY_HOSTNAME"/"$GCP_PROJECT"/"$IMAGE":"$RELEASE_VERSION" \ 45 | --build-arg GITHUB_SHA="$GITHUB_SHA" \ 46 | --build-arg GITHUB_REF="$GITHUB_REF" . 47 | 48 | - name: Publish 49 | run: docker push "$REGISTRY_HOSTNAME"/"$GCP_PROJECT"/"$IMAGE" 50 | -------------------------------------------------------------------------------- /packages/server/src/modules/senders/services/CreateSenderService.spec.ts: -------------------------------------------------------------------------------- 1 | import Sender from '@modules/senders/infra/mongoose/schemas/Sender'; 2 | import CreateSenderService from '@modules/senders/services/CreateSenderService'; 3 | 4 | import MongoMock from '@shared/tests/MongoMock'; 5 | 6 | describe('Create Sender', () => { 7 | beforeAll(async () => { 8 | await MongoMock.connect(); 9 | }); 10 | 11 | afterAll(async () => { 12 | await MongoMock.disconnect(); 13 | }); 14 | 15 | beforeEach(async () => { 16 | await Sender.deleteMany({}); 17 | }); 18 | 19 | it('should be able to create new sender', async () => { 20 | const createTemplate = new CreateSenderService(); 21 | 22 | const senderData = { 23 | name: 'John Doe', 24 | email: 'john@doe.com', 25 | }; 26 | 27 | await createTemplate.execute({ data: senderData }); 28 | 29 | const template = await Sender.findOne(senderData); 30 | 31 | expect(template).toBeTruthy(); 32 | }); 33 | 34 | it('should not be able to create duplicated sender', async () => { 35 | expect.assertions(1); 36 | 37 | const senderData = { 38 | name: 'John Doe', 39 | email: 'john@doe.com', 40 | }; 41 | 42 | await Sender.create(senderData); 43 | 44 | try { 45 | const createSender = new CreateSenderService(); 46 | 47 | await createSender.execute({ data: senderData }); 48 | } catch (err) { 49 | expect(err).toBeInstanceOf(Error); 50 | } 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /packages/web/src/components/Form/CodeInput/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.div` 4 | @import url('https://fonts.googleapis.com/css?family=Fira+Code:500&display=swap'); 5 | 6 | margin-bottom: 15px; 7 | 8 | label { 9 | display: block; 10 | margin-bottom: 8px; 11 | font-weight: bold; 12 | font-size: 16px; 13 | } 14 | 15 | > small { 16 | font-size: 13px; 17 | display: block; 18 | margin-bottom: 11px; 19 | opacity: 0.6; 20 | } 21 | 22 | .editor { 23 | min-height: 200px; 24 | display: block; 25 | margin-bottom: 15px; 26 | resize: vertical; 27 | margin-top: 3px; 28 | background: #15121e; 29 | border: 2px solid #15121e; 30 | color: #fff; 31 | border-radius: 4px; 32 | padding: 12px 15px; 33 | width: 100%; 34 | transition: border-color 0.2s; 35 | font-size: 16px; 36 | line-height: 24px; 37 | font-family: 'Fira Code'; 38 | font-weight: 500; 39 | 40 | &:focus { 41 | border-color: #7159c1; 42 | } 43 | 44 | .editorLineNumber { 45 | position: absolute; 46 | left: 0px; 47 | color: #cccccc; 48 | text-align: right; 49 | width: 40px; 50 | font-weight: 100; 51 | } 52 | 53 | textarea, 54 | pre { 55 | outline: none; 56 | padding-left: 60px !important; 57 | } 58 | } 59 | 60 | > span { 61 | color: #ce4a4a; 62 | display: block; 63 | margin-top: 5px; 64 | } 65 | `; 66 | -------------------------------------------------------------------------------- /packages/web/src/pages/SignIn/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { RouteComponentProps } from 'react-router-dom'; 3 | import { Form, Input } from '../../components/Form'; 4 | import Button from '../../components/Button'; 5 | import axios from '../../services/axios'; 6 | 7 | import logoImg from '../../assets/logo-full.svg'; 8 | 9 | import { Container, Content } from './styles'; 10 | 11 | type Props = RouteComponentProps; 12 | 13 | const SignIn: React.FC = ({ history }) => { 14 | const handleSingIn = useCallback( 15 | async data => { 16 | try { 17 | const response = await axios.post('/sessions', data); 18 | 19 | localStorage.setItem('@Umbriel:token', response.data.token); 20 | localStorage.setItem( 21 | '@Umbriel:user', 22 | JSON.stringify(response.data.user), 23 | ); 24 | 25 | history.push('/contacts'); 26 | } catch (err) { 27 | alert('Erro no login'); 28 | } 29 | }, 30 | [history], 31 | ); 32 | 33 | return ( 34 | 35 | 36 | Umbriel 37 | 38 |
39 | 40 | 41 | 42 | 45 |
46 |
47 |
48 | ); 49 | }; 50 | 51 | export default SignIn; 52 | -------------------------------------------------------------------------------- /packages/web/src/components/Sidebar/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.div` 4 | height: 100vh; 5 | width: 240px; 6 | background: #15121e; 7 | border-right: 1px solid #252131; 8 | 9 | display: flex; 10 | flex-direction: column; 11 | align-items: flex-start; 12 | padding: 24px; 13 | overflow: hidden; 14 | `; 15 | 16 | export const Nav = styled.nav` 17 | margin-top: 32px; 18 | 19 | a { 20 | color: inherit; 21 | display: block; 22 | font-weight: bold; 23 | text-decoration: none; 24 | text-transform: uppercase; 25 | padding: 12px 0; 26 | position: relative; 27 | opacity: 0.8; 28 | transition: opacity 0.2s; 29 | font-size: 13px; 30 | 31 | &.active { 32 | color: #fff; 33 | } 34 | 35 | &::before { 36 | content: ''; 37 | height: calc(100% - 8px); 38 | width: 4px; 39 | border-radius: 0 2px 2px 0; 40 | display: block; 41 | background: #7159c1; 42 | position: absolute; 43 | top: 50%; 44 | transform: translateY(-50%); 45 | left: -28px; 46 | transition: left 0.2s; 47 | } 48 | 49 | &.active::before { 50 | left: -24px; 51 | } 52 | } 53 | `; 54 | 55 | export const Profile = styled.button` 56 | margin-top: auto; 57 | display: flex; 58 | align-items: center; 59 | background: transparent; 60 | border: 0; 61 | color: inherit; 62 | 63 | span { 64 | font-weight: bold; 65 | margin: 0 5px 0 10px; 66 | } 67 | `; 68 | -------------------------------------------------------------------------------- /packages/server/src/modules/messages/services/CreateMessageService.spec.ts: -------------------------------------------------------------------------------- 1 | import Tag from '@modules/contacts/infra/mongoose/schemas/Tag'; 2 | import Message from '@modules/messages/infra/mongoose/schemas/Message'; 3 | import CreateMessageService from '@modules/messages/services/CreateMessageService'; 4 | 5 | import MongoMock from '@shared/tests/MongoMock'; 6 | 7 | describe('Create Message', () => { 8 | beforeAll(async () => { 9 | await MongoMock.connect(); 10 | }); 11 | 12 | afterAll(async () => { 13 | await MongoMock.disconnect(); 14 | }); 15 | 16 | beforeEach(async () => { 17 | await Tag.deleteMany({}); 18 | await Message.deleteMany({}); 19 | }); 20 | 21 | it('should be able to create new message', async () => { 22 | const createMessage = new CreateMessageService(); 23 | 24 | const tags = await Tag.create([ 25 | { title: 'Students' }, 26 | { title: 'Class A' }, 27 | ]); 28 | 29 | const tagsIds: string[] = tags.map((tag: { _id: string }) => tag._id); 30 | 31 | const messageData = { 32 | sender: { 33 | name: 'John Doe', 34 | email: 'john@doe.com', 35 | }, 36 | subject: 'Hello World', 37 | body: '

Just testing the email

', 38 | finalBody: '

Just testing the email

', 39 | tags: tagsIds, 40 | }; 41 | 42 | await createMessage.execute({ 43 | data: messageData, 44 | }); 45 | 46 | const message = await Message.findOne(messageData); 47 | 48 | expect(message).toBeTruthy(); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /packages/web/src/components/Form/Input/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from 'react'; 2 | import { useField } from '@unform/core'; 3 | 4 | import { Container } from './styles'; 5 | 6 | interface Props { 7 | label?: string; 8 | note?: string; 9 | name: string; 10 | multiline?: Multiline; 11 | } 12 | 13 | type InputProps = JSX.IntrinsicElements['input'] & Props; 14 | type TextAreaProps = JSX.IntrinsicElements['textarea'] & Props; 15 | 16 | const Input: React.FC = ({ 17 | label, 18 | note, 19 | name, 20 | multiline, 21 | ...rest 22 | }) => { 23 | const inputRef = useRef(null); 24 | const { fieldName, defaultValue, registerField, error } = useField(name); 25 | 26 | useEffect(() => { 27 | registerField({ 28 | name: fieldName, 29 | path: 'value', 30 | ref: inputRef.current, 31 | }); 32 | }, [fieldName, registerField]); 33 | 34 | const props = { 35 | ...rest, 36 | ref: inputRef, 37 | id: fieldName, 38 | 'aria-label': fieldName, 39 | defaultValue, 40 | }; 41 | 42 | return ( 43 | 44 | {label && } 45 | {note && {note}} 46 | 47 | {multiline ? ( 48 |