├── .env.example ├── src ├── telegram │ ├── commands │ │ ├── getId.ts │ │ ├── deleteProfile.ts │ │ ├── start.ts │ │ ├── deleteToken.ts │ │ ├── help.ts │ │ ├── setChatsId.ts │ │ ├── filterEmails.ts │ │ └── connectGmail.ts │ ├── common.ts │ └── index.ts ├── server │ ├── serverMap.ts │ └── index.ts ├── service │ ├── date.ts │ └── logging.ts ├── index.ts ├── db │ ├── model │ │ └── user.ts │ └── controller │ │ └── user.ts └── gmail │ ├── pushUpdates.ts │ └── index.ts ├── tslint.json ├── README.md ├── tsconfig.json ├── .github └── workflows │ ├── nodejs.yml │ ├── codacy-analysis.yml │ └── codeql-analysis.yml ├── LICENSE ├── .gitignore └── package.json /.env.example: -------------------------------------------------------------------------------- 1 | BOT_TOKEN = "..." 2 | DB_URL = "mongodb://..." 3 | PORT = 1000 4 | WEBHOOK_TG_PATH = "/path" 5 | GAPPS_PUSH_PATH = "/path" 6 | SERVER_PATH = "https://server.tld" 7 | GOOGLE_SITE_VERIFICATION = "google..." 8 | PUB_SUB_TOPIC = "projects/PROJECT_ID/topics/TOPIC_NAME" 9 | UPDATE_PUB_SUB_TOPIC_PATH = "/path" 10 | GOOGLE_CREDENTIALS = "credentials" -------------------------------------------------------------------------------- /src/telegram/commands/getId.ts: -------------------------------------------------------------------------------- 1 | import { Context, Middleware } from "telegraf"; 2 | import { BotCommand } from "@telegram/common"; 3 | 4 | const getId: Middleware = async function(ctx) { 5 | ctx.reply(ctx.chat.id.toString()); 6 | }; 7 | 8 | export const desrciption: BotCommand = { 9 | command: "get_id", 10 | description: "Get ID of current chat" 11 | }; 12 | 13 | export default getId; 14 | -------------------------------------------------------------------------------- /src/server/serverMap.ts: -------------------------------------------------------------------------------- 1 | import { Application } from "express"; 2 | 3 | export function setValue(app: Application, key: string, value: TVal) { 4 | app.set(key, value); 5 | } 6 | 7 | export function getValue(app: Application, key: string): TVal { 8 | return app.get(key); 9 | } 10 | 11 | export function isValueSet(app: Application, key: string) { 12 | return typeof app.get(key) !== "undefined"; 13 | } 14 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ "tslint:recommended" ], 3 | "rules": { 4 | "trailing-comma": false, 5 | "member-access": false, 6 | "ordered-imports": false, 7 | "variable-name": false, 8 | "no-console": false, 9 | "no-namespace": false, 10 | "object-literal-sort-keys": false, 11 | "only-arrow-functions": false, 12 | "object-literal-key-quotes": false 13 | } 14 | } -------------------------------------------------------------------------------- /src/service/date.ts: -------------------------------------------------------------------------------- 1 | export function toFormatedString(date: Date) { 2 | const y = date.getFullYear(); 3 | const m = `${(date.getMonth() + 1) > 9 ? "" : "0"}` + (date.getMonth() + 1); // getMonth() is zero-based 4 | const d = `${date.getDate() > 9 ? "" : "0"}` + date.getDate(); 5 | const H = `${date.getHours() > 9 ? "" : "0"}` + date.getHours(); 6 | const M = `${date.getMinutes() > 9 ? "" : "0"}` + date.getMinutes(); 7 | const S = `${date.getSeconds() > 9 ? "" : "0"}` + date.getSeconds(); 8 | return `${H}:${M}:${S} ${d}.${m}.${y}`; 9 | } 10 | -------------------------------------------------------------------------------- /src/service/logging.ts: -------------------------------------------------------------------------------- 1 | export function error(e: Error) { 2 | console.error(log("Error", e.message)); 3 | console.log(e.stack); 4 | } 5 | 6 | export function warning(what: string) { 7 | console.warn(log("Warning", what)); 8 | } 9 | 10 | export function success(what: string) { 11 | console.log(log("Success", what)); 12 | } 13 | 14 | export function info(what: string) { 15 | console.info(log("Info", what)); 16 | } 17 | 18 | function log(type: string, what: string) { 19 | return `[${new Date().toISOString()}]: ${type}: ${what}`; 20 | } 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gmail-tg-notifications 2 | 3 | [![GitHub](https://img.shields.io/github/license/akrava/gmail-tg-notifications)](https://github.com/akrava/gmail-tg-notifications/blob/master/LICENSE) 4 | [![Gitter](https://badges.gitter.im/gmail-tg-notifications/community.svg)](https://gitter.im/gmail-tg-notifications/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) 5 | [![Telegram chat](https://img.shields.io/badge/chat-on%20telegram-blue)](https://t.me/joinchat/HJZBKy7kv9EVCUTe) 6 | [![Telegram bot](https://img.shields.io/badge/try-telegram%20bot-blue)](https://t.me/gmail_notifications_bot) 7 | 8 | Deployed tg bot: [link](https://t.me/gmail_notifications_bot) 9 | -------------------------------------------------------------------------------- /src/telegram/common.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "telegraf"; 2 | import { FindUserById } from "@controller/user"; 3 | 4 | export async function checkUser(ctx: Context) { 5 | if (ctx.chat.type !== "private") { 6 | ctx.reply("Use private chat."); 7 | return false; 8 | } 9 | const user = await FindUserById(ctx.chat.id); 10 | if (user === false) { 11 | ctx.reply("You are not registered. /start to proceed"); 12 | return false; 13 | } else if (typeof user === "undefined") { 14 | ctx.reply("Error ocurred, contact to maintainer"); 15 | return false; 16 | } else { 17 | return user; 18 | } 19 | } 20 | 21 | export interface BotCommand { 22 | description: string; 23 | command: string; 24 | } 25 | -------------------------------------------------------------------------------- /src/telegram/commands/deleteProfile.ts: -------------------------------------------------------------------------------- 1 | import { Context, MiddlewareFn } from "telegraf"; 2 | import { DeleteUser } from "@controller/user"; 3 | import { checkUser, BotCommand } from "@telegram/common"; 4 | 5 | 6 | const deleteProfile: MiddlewareFn = async function(ctx) { 7 | const user = await checkUser(ctx); 8 | if (user === false) { 9 | return; 10 | } 11 | 12 | if ((await DeleteUser(user.telegramID))) { 13 | await ctx.reply("successfully deleted user from db"); 14 | } else { 15 | await ctx.reply("error ocurred"); 16 | } 17 | }; 18 | 19 | export const desrciption: BotCommand = { 20 | command: "delete_profile", 21 | description: "Delete your profile with creds" 22 | }; 23 | 24 | export default deleteProfile; 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src", 4 | "paths": { 5 | "@telegram/*": [ "telegram/*" ], 6 | "@gmail/*": [ "gmail/*" ], 7 | "@service/*": [ "service/*" ], 8 | "@server/*": [ "server/*" ], 9 | "@model/*": [ "db/model/*" ], 10 | "@controller/*": [ "db/controller/*" ], 11 | "@commands/*": [ "telegram/commands/*" ], 12 | "@ambient/*": [ "ambient_declarations/*" ] 13 | }, 14 | "module": "commonjs", 15 | "target": "esnext", 16 | "outDir": "build", 17 | "rootDir": "src", 18 | "noImplicitAny": true, 19 | "esModuleInterop": true 20 | }, 21 | "compileOnSave": true 22 | } -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v1 10 | - name: Use Node.js 12.x 11 | uses: actions/setup-node@v1 12 | with: 13 | node-version: 12.x 14 | - name: npm install, build, and test 15 | run: | 16 | npm ci 17 | npm run build 18 | env: 19 | CI: true 20 | - name: deploy to Heroku 21 | env: 22 | HEROKU_API_TOKEN: ${{ secrets.HEROKU_API_TOKEN }} 23 | HEROKU_APP_NAME: ${{ secrets.HEROKU_APP_NAME }} 24 | if: github.ref == 'refs/heads/master' && job.status == 'success' 25 | run: git push https://heroku:$HEROKU_API_TOKEN@git.heroku.com/$HEROKU_APP_NAME.git origin/master:master 26 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | dotenv.config(); 3 | import { bot as TelegramBot } from "@telegram/index"; 4 | import { app as ServerApp } from "@server/index"; 5 | import { error, info } from "@service/logging"; 6 | import Mongoose from "mongoose"; 7 | import url from "url"; 8 | 9 | const connectionsOptions = { 10 | useNewUrlParser: true, useCreateIndex: true, useFindAndModify: false, 11 | useUnifiedTopology: true 12 | }; 13 | 14 | const SERVER_PATH = process.env.SERVER_PATH; 15 | const WEBHOOK_TG_PATH = process.env.WEBHOOK_TG_PATH; 16 | const PORT = process.env.PORT; 17 | const DB_URL = process.env.DB_URL; 18 | const completeWebhookTgPath = url.resolve(SERVER_PATH, WEBHOOK_TG_PATH); 19 | 20 | Mongoose.connect(DB_URL, connectionsOptions) 21 | .catch(err => err ? error(err) : info("Opened connection with db")) 22 | .then(() => ServerApp.listen(PORT, () => info(`Running on port ${PORT}`))) 23 | .then(() => TelegramBot.telegram.setWebhook(completeWebhookTgPath)) 24 | .then(res => res ? info("Tg bot started") : error(new Error("webhook error"))) 25 | .catch(err => error(err)); 26 | -------------------------------------------------------------------------------- /src/server/index.ts: -------------------------------------------------------------------------------- 1 | import Express, { Request, Response, NextFunction } from "express"; 2 | import { bot } from "@telegram/index"; 3 | import rateLimit from "express-rate-limit"; 4 | // import mongoSanitize from "express-mongo-sanitize"; 5 | import { router as gmailRouter } from "@gmail/index"; 6 | 7 | export const app = Express(); 8 | 9 | app.set('trust proxy', 1); 10 | 11 | const limiter = rateLimit({ 12 | windowMs: 15 * 60 * 1000, // 15 minutes 13 | max: 100 // limit each IP to 100 requests per windowMs 14 | }); 15 | 16 | app.use(limiter); 17 | 18 | // app.use(mongoSanitize()); 19 | 20 | app.use(bot.webhookCallback(process.env.WEBHOOK_TG_PATH)); 21 | 22 | app.use(gmailRouter); 23 | 24 | app.get(`/${process.env.GOOGLE_SITE_VERIFICATION}.html`, (_req, res) => { 25 | res.setHeader("Content-Type", "text/html; charset=utf-8"); 26 | res.send(`google-site-verification: ${process.env.GOOGLE_SITE_VERIFICATION}.html`); 27 | }); 28 | 29 | app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { 30 | console.error(err); 31 | console.trace(); 32 | res.status(500); 33 | }); 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Arkadiy Krava 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. -------------------------------------------------------------------------------- /src/db/model/user.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from "mongoose"; 2 | import { Schema, Document } from "mongoose"; 3 | 4 | export interface IUser extends Document { 5 | telegramID: number; 6 | chatsId: number[]; 7 | token: string; 8 | email: string; 9 | historyId: number; 10 | senderEmailsToFilter?: string[]; 11 | filterActionIsBlock?: boolean; 12 | } 13 | 14 | const UserSchema: Schema = new Schema({ 15 | telegramID: { type: Number, required: true, unique: true }, 16 | chatsId: { type: [Number], required: true, default: [] }, 17 | token: { type: String, required: true, default: " " }, 18 | email: { type: String, required: true, unique: true, 19 | lowercase: true, trim: true }, 20 | historyId: { type: Number, required: true, default: 0 }, 21 | senderEmailsToFilter: { type: [String], required: false, default: null }, 22 | filterActionIsBlock: { type: Boolean, required: false, default: null }, 23 | }); 24 | 25 | export default mongoose.model("User", UserSchema); 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # extra 64 | build/ 65 | .vscode/ -------------------------------------------------------------------------------- /src/telegram/commands/start.ts: -------------------------------------------------------------------------------- 1 | import { CreateUser, FindUserById } from "@controller/user"; 2 | import { Middleware, Context } from "telegraf"; 3 | import { BotCommand } from "@telegram/common"; 4 | 5 | const start: Middleware = async function(ctx) { 6 | if (ctx.chat.type === "private") { 7 | const user = await FindUserById(ctx.chat.id); 8 | if (user === false) { 9 | const newUser = await CreateUser({ 10 | telegramID: ctx.chat.id, 11 | email: ctx.chat.id.toString(), 12 | chatsId: [ctx.chat.id] 13 | }); 14 | if (typeof newUser !== "undefined") { 15 | ctx.reply("Successfully registered. Now you can adjust it. /help to see more"); 16 | } else { 17 | ctx.reply("Error ocurred while registering"); 18 | } 19 | } else if (typeof user === "undefined") { 20 | ctx.reply("Error ocurred, contact to maintainer"); 21 | } else { 22 | ctx.reply("You are registered"); 23 | } 24 | } else { 25 | ctx.reply("To start using this service you should send command start in private chat"); 26 | } 27 | }; 28 | 29 | export const desrciption: BotCommand = { 30 | command: "start", 31 | description: "Start using this bot" 32 | }; 33 | 34 | export default start; 35 | -------------------------------------------------------------------------------- /src/telegram/commands/deleteToken.ts: -------------------------------------------------------------------------------- 1 | import { Context, MiddlewareFn } from "telegraf"; 2 | import { DeleteCredentials } from "@controller/user"; 3 | import { checkUser, BotCommand } from "@telegram/common"; 4 | import { authorizeUser, stopNotifications } from "@gmail/index"; 5 | 6 | 7 | const deleteToken: MiddlewareFn = async function(ctx) { 8 | const user = await checkUser(ctx); 9 | if (user === false) { 10 | return; 11 | } 12 | const obj = await authorizeUser(user.telegramID); 13 | if (obj !== null) { 14 | if (obj.authorized) { 15 | if (!(await stopNotifications(obj.oauth))) { 16 | await ctx.reply("error while stopping notifications"); 17 | } else { 18 | await ctx.reply("Unsubscribed"); 19 | } 20 | } else { 21 | await ctx.reply("Not authorized"); 22 | } 23 | } else { 24 | await ctx.reply("Error ocurred: auth obj is null"); 25 | } 26 | 27 | if ((await DeleteCredentials(user.telegramID))) { 28 | await ctx.reply("successfully deleted token"); 29 | } else { 30 | await ctx.reply("error ocurred"); 31 | } 32 | }; 33 | 34 | export const desrciption: BotCommand = { 35 | command: "delete_token", 36 | description: "Unsubscribe from email updates and delete Gmail token" 37 | }; 38 | 39 | export default deleteToken; 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gmail-tg-notifications", 3 | "version": "1.0.0", 4 | "description": "recive gmail notifications via telegram", 5 | "main": "index.js", 6 | "scripts": { 7 | "build-server": "tsc && tscpaths -p tsconfig.json -s ./src -o ./build", 8 | "build": "npm run clean && npm run build-server", 9 | "clean": "rm -rf build", 10 | "heroku-postbuild": "npm run build", 11 | "start": "node build/index.js" 12 | }, 13 | "author": "akrava", 14 | "license": "MIT", 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/akrava/gmail-tg-notifications.git" 18 | }, 19 | "dependencies": { 20 | "body-parser": "^1.19.0", 21 | "dotenv": "^8.6.0", 22 | "express": "^4.17.1", 23 | "express-mongo-sanitize": "^2.1.0", 24 | "express-rate-limit": "^5.3.0", 25 | "google-auth-library": "^6.1.6", 26 | "googleapis": "^66.0.0", 27 | "html-to-text": "^5.1.1", 28 | "mongoose": "^5.13.5", 29 | "telegraf": "^4.4.1" 30 | }, 31 | "devDependencies": { 32 | "@types/body-parser": "^1.19.1", 33 | "@types/dotenv": "^6.1.1", 34 | "@types/express": "^4.17.13", 35 | "@types/express-mongo-sanitize": "^1.3.2", 36 | "@types/express-rate-limit": "^5.1.3", 37 | "@types/html-to-text": "^1.4.31", 38 | "tscpaths": "0.0.9", 39 | "tslint": "^5.20.1", 40 | "typescript": "^4.3.5" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/telegram/index.ts: -------------------------------------------------------------------------------- 1 | import { Telegraf, session, Scenes } from "telegraf"; 2 | import { error } from "@service/logging"; 3 | import startCb, { desrciption as startCommand } from "@commands/start"; 4 | import connectGmailCb, { desrciption as connectGmailCommand } from "@commands/connectGmail"; 5 | import setChatsId, { desrciption as setChatsIdCommand } from "@commands/setChatsId"; 6 | import getId, { desrciption as getIdCommand } from "@commands/getId"; 7 | import help, { desrciption as helpCommand } from "@commands/help"; 8 | import deleteTokenCb, { desrciption as deleteTokenCommand } from "@commands/deleteToken"; 9 | import deleteProfileCb, { desrciption as deleteProfileCommand } from "@commands/deleteProfile"; 10 | import filterEmailsCb , { desrciption as filterEmailsCommand } from "@commands/filterEmails"; 11 | import { stage as authGmailStage } from "@commands/connectGmail"; 12 | 13 | export const bot = new Telegraf(process.env.BOT_TOKEN); 14 | 15 | bot.use(session()); 16 | bot.use(authGmailStage.middleware()); 17 | bot.start(startCb); 18 | bot.command(connectGmailCommand.command, connectGmailCb); 19 | bot.command(setChatsIdCommand.command, setChatsId); 20 | bot.command(getIdCommand.command, getId); 21 | bot.command(deleteTokenCommand.command, deleteTokenCb); 22 | bot.command(deleteProfileCommand.command, async (ctx) => { 23 | await deleteTokenCb(ctx, null); 24 | await deleteProfileCb(ctx, null); 25 | }); 26 | bot.command(filterEmailsCommand.command, filterEmailsCb); 27 | bot.help(help); 28 | 29 | bot.telegram.setMyCommands([startCommand, connectGmailCommand, setChatsIdCommand, helpCommand, 30 | filterEmailsCommand, getIdCommand, deleteTokenCommand, deleteProfileCommand]) 31 | .catch(e => error(e)); 32 | 33 | bot.catch((err: Error) => error(err)); 34 | 35 | process.once('SIGINT', () => bot.stop('SIGINT')); 36 | process.once('SIGTERM', () => bot.stop('SIGTERM')); 37 | -------------------------------------------------------------------------------- /.github/workflows/codacy-analysis.yml: -------------------------------------------------------------------------------- 1 | # This workflow checks out code, performs a Codacy security scan 2 | # and integrates the results with the 3 | # GitHub Advanced Security code scanning feature. For more information on 4 | # the Codacy security scan action usage and parameters, see 5 | # https://github.com/codacy/codacy-analysis-cli-action. 6 | # For more information on Codacy Analysis CLI in general, see 7 | # https://github.com/codacy/codacy-analysis-cli. 8 | 9 | name: Codacy Security Scan 10 | 11 | on: 12 | push: 13 | branches: [ master ] 14 | pull_request: 15 | branches: [ master ] 16 | 17 | jobs: 18 | codacy-security-scan: 19 | name: Codacy Security Scan 20 | runs-on: ubuntu-latest 21 | steps: 22 | # Checkout the repository to the GitHub Actions runner 23 | - name: Checkout code 24 | uses: actions/checkout@v2 25 | 26 | # Execute Codacy Analysis CLI and generate a SARIF output with the security issues identified during the analysis 27 | - name: Run Codacy Analysis CLI 28 | uses: codacy/codacy-analysis-cli-action@1.1.0 29 | with: 30 | # Check https://github.com/codacy/codacy-analysis-cli#project-token to get your project token from your Codacy repository 31 | # You can also omit the token and run the tools that support default configurations 32 | # project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} 33 | verbose: true 34 | output: results.sarif 35 | format: sarif 36 | # Adjust severity of non-security issues 37 | gh-code-scanning-compat: true 38 | # Force 0 exit code to allow SARIF file generation 39 | # This will handover control about PR rejection to the GitHub side 40 | max-allowed-issues: 2147483647 41 | 42 | # Upload the SARIF file generated in the previous step 43 | - name: Upload SARIF results file 44 | uses: github/codeql-action/upload-sarif@v1 45 | with: 46 | sarif_file: results.sarif 47 | -------------------------------------------------------------------------------- /src/telegram/commands/help.ts: -------------------------------------------------------------------------------- 1 | import { Context, Middleware } from "telegraf"; 2 | import { BotCommand } from "@telegram/common"; 3 | 4 | const help: Middleware = async function(ctx) { 5 | ctx.replyWithHTML( 6 | "Tap /start to get started.\n\n" + 7 | "Tap /connect_gmail to subcribe for new emails.\n\n" + 8 | "To forward emails from gmail into specific chats " + 9 | "or channels you should enter command /set_chats and " + 10 | "list of chats ID separeted by whitespaces on second " + 11 | "line in such format :\n" + 12 | "
" +
13 |         "/set_chats\n" +
14 |         "0000 0000 0000 0000" +
15 |         "
\n\n" + 16 | "Use /filter_emails command to filter incoming mails from senders email " + 17 | "addressess.\nTo set filter rule to block only emails from specified " + 18 | "senders you should type such message, where on the second line there " + 19 | "should be block word and on the third line there should " + 20 | "be list of senders emails separeted by whitespaces. Example:\n" + 21 | "
" +
22 |         "/filter_emails\n" +
23 |         "block\n" +
24 |         "john@example.com tom@simple.org" +
25 |         "
\n" + 26 | "To set filter rule to allow only emails from specified senders you " + 27 | "should type such message, where on the second line there should be " + 28 | "allow word and on the third line there should be list " + 29 | "of senders emails separeted by whitespaces. Example:\n" + 30 | "
" +
31 |         "/filter_emails\n" +
32 |         "allow\n" +
33 |         "john@example.com tom@simple.org" +
34 |         "
\n\n" + 35 | "Tap /delete_token to unsubscribe from gmail updates and delete creds.\n\n" + 36 | "Tap /delete_profile to unsubscribe, delete creds and profile from DB.\n\n" + 37 | "Chats or channels ID you can get here: @userinfobot.\n" + 38 | "Tap /get_id to get id of group chat." 39 | ); 40 | }; 41 | 42 | export const desrciption: BotCommand = { 43 | command: "help", 44 | description: "How to use this bot" 45 | }; 46 | 47 | export default help; 48 | -------------------------------------------------------------------------------- /src/telegram/commands/setChatsId.ts: -------------------------------------------------------------------------------- 1 | import { bot } from "@telegram/index"; 2 | import { Context, Middleware } from "telegraf"; 3 | import { SetChatsId as SetChatsIdController } from "@controller/user"; 4 | import { checkUser, BotCommand } from "@telegram/common"; 5 | import { error } from "@service/logging"; 6 | 7 | const setChatsId: Middleware = async function(ctx) { 8 | const user = await checkUser(ctx); 9 | if (user === false) { 10 | return; 11 | } 12 | if (!("text" in ctx.message)) { 13 | return; 14 | } 15 | const lines = ctx.message.text.split(/[\r\n]+/); 16 | if (lines.length !== 2) { 17 | ctx.reply("expected two lines"); 18 | return; 19 | } 20 | lines.splice(0, 1); 21 | const data = lines[0].match(/\S+/g) || []; 22 | let chatsId; 23 | try { 24 | chatsId = data.map(Number); 25 | if (!chatsId.every((x) => Number.isInteger(x))) { 26 | throw new Error("not a numer"); 27 | } 28 | } catch (e) { 29 | error(e); 30 | ctx.reply("not a number"); 31 | return; 32 | } 33 | const botID = (await bot.telegram.getMe()).id; 34 | const chats: number[] = []; 35 | for (const x of chatsId) { 36 | let chat; 37 | try { 38 | chat = await bot.telegram.getChat(x); 39 | } catch (e) { 40 | continue; 41 | } 42 | if (chat.type !== "private") { 43 | const admins = await bot.telegram.getChatAdministrators(x); 44 | const isUserAdmin = admins.some((y) => y.user.id === user.telegramID); 45 | const isBotAdmin = admins.some((y) => y.user.id === botID); 46 | if (isBotAdmin && isUserAdmin) { 47 | chats.push(x); 48 | } 49 | } else { 50 | chats.push(x); 51 | } 52 | } 53 | if ((await SetChatsIdController(user.telegramID, chats))) { 54 | ctx.reply(chats.reduce((prev, cur) => (prev.toString() + cur.toString() + "\n"), "")); 55 | } else { 56 | ctx.reply("error ocurred"); 57 | } 58 | }; 59 | 60 | export const desrciption: BotCommand = { 61 | command: "set_chats", 62 | description: "Set chats IDs where to forward emails" 63 | }; 64 | 65 | export default setChatsId; 66 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '42 12 * * 6' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'javascript' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v2 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v1 43 | with: 44 | languages: ${{ matrix.language }} 45 | # If you wish to specify custom queries, you can do so here or in a config file. 46 | # By default, queries listed here will override any specified in a config file. 47 | # Prefix the list here with "+" to use these queries and those in the config file. 48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 49 | 50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 51 | # If this step fails, then you should remove it and run the build manually (see below) 52 | - name: Autobuild 53 | uses: github/codeql-action/autobuild@v1 54 | 55 | # ℹ️ Command-line programs to run using the OS shell. 56 | # 📚 https://git.io/JvXDl 57 | 58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 59 | # and modify them (or add more) to build your code if your project 60 | # uses a compiled language 61 | 62 | #- run: | 63 | # make bootstrap 64 | # make release 65 | 66 | - name: Perform CodeQL Analysis 67 | uses: github/codeql-action/analyze@v1 68 | -------------------------------------------------------------------------------- /src/telegram/commands/filterEmails.ts: -------------------------------------------------------------------------------- 1 | import { Context, MiddlewareFn } from "telegraf"; 2 | import { SetSenderEmailsToFilterAndAction } from "@controller/user"; 3 | import { checkUser, BotCommand } from "@telegram/common"; 4 | 5 | const filterEmails: MiddlewareFn = async function(ctx) { 6 | const user = await checkUser(ctx); 7 | if (user === false) { 8 | return; 9 | } 10 | if (!("text" in ctx.message)) { 11 | return; 12 | } 13 | const lines = ctx.message.text.split(/[\r\n]+/); 14 | switch(lines.length) { 15 | case 3: { 16 | const secondLine = lines[1]; 17 | const thirdLine = lines[2]; 18 | let isActionBlock; 19 | switch (secondLine) { 20 | case "allow": { 21 | isActionBlock = false; 22 | } break; 23 | case "block": { 24 | isActionBlock = true; 25 | } break; 26 | default: { 27 | await ctx.reply("Expected `allow` or `block` on the second line."); 28 | return; 29 | } 30 | } 31 | const emails = Array.from(thirdLine.match(/\S+/g)) || []; 32 | emails.map(x => x.toLowerCase()); 33 | if ((await SetSenderEmailsToFilterAndAction(user.telegramID, emails, isActionBlock))) { 34 | await ctx.reply( 35 | `Filter rule was successfully set to ` + 36 | `${ isActionBlock ? "block" : "allow" } only emails from such senders:\n` + 37 | `${emails.reduce((prev, cur) => (prev.toString() + cur.toString() + "\n"), "")}` 38 | ); 39 | } else { 40 | await ctx.reply("Error ocurred"); 41 | } 42 | } break; 43 | case 2: { 44 | if (lines[1].trim() === "no") { 45 | if ((await SetSenderEmailsToFilterAndAction(user.telegramID, null, null))) { 46 | await ctx.reply("Successfully disabled all filters."); 47 | } else { 48 | await ctx.reply("Error ocurred"); 49 | } 50 | } else { 51 | await ctx.replyWithHTML( 52 | "Expected no to disable filter. You should send such " + 53 | `messsage to disable filtering: \n${desrciption.command}\nno` + 54 | "\nNothing to do." 55 | ); 56 | } 57 | } break; 58 | default: { 59 | await ctx.reply("Expected two or three lines. Refer /help to get more info."); 60 | } 61 | } 62 | }; 63 | 64 | export const desrciption: BotCommand = { 65 | command: "filter_emails", 66 | description: "Set list of senders email addresses which should be filtered" 67 | }; 68 | 69 | export default filterEmails; 70 | -------------------------------------------------------------------------------- /src/telegram/commands/connectGmail.ts: -------------------------------------------------------------------------------- 1 | import { FindUserById, SetEmail } from "@controller/user"; 2 | import { checkUser, BotCommand } from "@telegram/common"; 3 | import { Middleware, Scenes, Context } from "telegraf"; 4 | import { authorizeUser, generateUrlToGetToken, getNewToken, IAuthObject } from "@gmail/index"; 5 | import { getEmailAdress, watchMails } from "@gmail/index"; 6 | import { SceneContextScene } from "telegraf/typings/scenes"; 7 | 8 | const gmailConnectScene = new Scenes.BaseScene("connect_gmail"); 9 | gmailConnectScene.enter(async (ctx) => { 10 | const user = await FindUserById(ctx.chat.id); 11 | if (!user) { 12 | ctx.reply("Error ocurred"); 13 | return ctx.scene.leave(); 14 | } 15 | const obj = await authorizeUser(user.telegramID); 16 | if (obj !== null) { 17 | if (obj.authorized) { 18 | // ctx.reply(""); 19 | await ctx.reply("Successfully authorized from cache"); 20 | if ((await watchMails(user.telegramID, obj.oauth))) { 21 | await ctx.reply("Subscribed for new emails successfully"); 22 | return ctx.scene.leave(); 23 | } else { 24 | await ctx.reply("Error ocurred, couldn't subscribe"); 25 | return ctx.scene.leave(); 26 | } 27 | } else { 28 | const url = generateUrlToGetToken(obj.oauth); 29 | // ctx.reply(""); 30 | await ctx.reply("You need to authorize at gmail. Open link below to get token. To cancel tap /cancel"); 31 | await ctx.reply(url); 32 | await ctx.reply("Enter token:"); 33 | ctx.scene.session.state = obj; 34 | } 35 | } else { 36 | ctx.reply("Error ocurred"); 37 | return ctx.scene.leave(); 38 | } 39 | }); 40 | gmailConnectScene.leave((ctx) => ctx.reply("Gmail config finished")); 41 | gmailConnectScene.on("text", async (ctx) => { 42 | const user = await FindUserById(ctx.chat.id); 43 | if (!user) { 44 | ctx.reply("Error ocurred"); 45 | return ctx.scene.leave(); 46 | } 47 | const obj = ctx.scene.session.state as IAuthObject; 48 | const auth = await getNewToken(ctx.chat.id, obj.oauth, ctx.message.text); 49 | if (auth === null) { 50 | ctx.reply("Error ocurred, bad token"); 51 | return ctx.scene.leave(); 52 | } else { 53 | await ctx.reply("Successfully authorized"); 54 | const email = await getEmailAdress(auth); 55 | if (!email || !(await SetEmail(user.telegramID, email))) { 56 | await ctx.reply("Error ocurred, couldn't subscribe"); 57 | return ctx.scene.leave(); 58 | } 59 | if ((await watchMails(user.telegramID, auth))) { 60 | await ctx.reply("Subscribed for new emails successfully"); 61 | return ctx.scene.leave(); 62 | } else { 63 | await ctx.reply("Error ocurred, couldn't subscribe"); 64 | return ctx.scene.leave(); 65 | } 66 | } 67 | }); 68 | gmailConnectScene.command("cancel", Scenes.Stage.leave()); 69 | 70 | export const stage = new Scenes.Stage([gmailConnectScene]); 71 | 72 | const connectGmail: Middleware = async function(ctx) { 73 | const user = await checkUser(ctx); 74 | if (user !== false) { 75 | ctx.scene.enter("connect_gmail"); 76 | } 77 | }; 78 | 79 | export const desrciption: BotCommand = { 80 | command: "connect_gmail", 81 | description: "Subscribe to watch new emails" 82 | }; 83 | 84 | export default connectGmail; 85 | -------------------------------------------------------------------------------- /src/db/controller/user.ts: -------------------------------------------------------------------------------- 1 | import User, { IUser } from "@model/user"; 2 | import { error } from "@service/logging"; 3 | 4 | export interface ICreateUserInput { 5 | telegramID: IUser["telegramID"]; 6 | chatsId?: IUser["chatsId"]; 7 | token?: IUser["token"]; 8 | email: IUser["email"]; 9 | historyId?: IUser["historyId"]; 10 | senderEmailsToFilter?: IUser["senderEmailsToFilter"]; 11 | filterActionIsBlock?: IUser["filterActionIsBlock"]; 12 | } 13 | 14 | export async function CreateUser(obj: ICreateUserInput) { 15 | return User.create({ 16 | telegramID: obj.telegramID, 17 | chatsId: obj.chatsId, 18 | token: obj.token, 19 | email: obj.email, 20 | historyId: obj.historyId, 21 | senderEmailsToFilter: obj.senderEmailsToFilter, 22 | filterActionIsBlock: obj.filterActionIsBlock 23 | }) 24 | .then((data: IUser) => { 25 | return data; 26 | }) 27 | .catch((e: Error) => { 28 | error(e); 29 | }); 30 | } 31 | 32 | export async function FindUserById(tgId: IUser["telegramID"]) { 33 | return User.findOne({ telegramID: tgId }) 34 | .then((data: IUser) => { 35 | return data || false; 36 | }) 37 | .catch((e: Error) => { 38 | error(e); 39 | }); 40 | } 41 | 42 | export async function FindAll() { 43 | return User.find({}).then((x) => x).catch((e) => (error(e), false)); 44 | } 45 | 46 | export async function FindUserByEmail(email: IUser["email"]) { 47 | return User.findOne({ email }) 48 | .then((data: IUser) => { 49 | return data || false; 50 | }) 51 | .catch((e: Error) => { 52 | error(e); 53 | }); 54 | } 55 | 56 | export async function SetChatsId(tgId: IUser["telegramID"], chatsId: IUser["chatsId"]) { 57 | return User.findOneAndUpdate({ telegramID: tgId }, { $set: { chatsId } }, { upsert: true }) 58 | .then(() => true).catch((e) => (error(e), false)); 59 | } 60 | 61 | export async function SetToken(tgId: IUser["telegramID"], token: IUser["token"]) { 62 | return User.findOneAndUpdate({ telegramID: tgId }, { $set: { token } }, { upsert: true }) 63 | .then(() => true).catch((e) => (error(e), false)); 64 | } 65 | 66 | export async function SetHistoryId(tgId: IUser["telegramID"], hId: IUser["historyId"]) { 67 | return User.findOneAndUpdate({ telegramID: tgId }, { $set: { historyId: hId } }, { upsert: true }) 68 | .then(() => true).catch((e) => (error(e), false)); 69 | } 70 | 71 | export async function SetEmail(tgId: IUser["telegramID"], email: IUser["email"]) { 72 | return User.findOneAndUpdate({ telegramID: tgId }, { $set: { email } }, { upsert: true }) 73 | .then(() => true).catch((e) => (error(e), false)); 74 | } 75 | 76 | export async function SetSenderEmailsToFilterAndAction( 77 | tgId: IUser["telegramID"], senderEmailsToFilter?: IUser["senderEmailsToFilter"], 78 | filterActionIsBlock?: IUser["filterActionIsBlock"] 79 | ) { 80 | return User.findOneAndUpdate({ telegramID: tgId }, { 81 | $set: { senderEmailsToFilter, filterActionIsBlock } 82 | }, { upsert: true }) 83 | .then(() => true).catch((e) => (error(e), false)); 84 | } 85 | 86 | export async function DeleteCredentials(tgId: IUser["telegramID"]) { 87 | return User.findOneAndUpdate({ telegramID: tgId }, { $set: {token: " ", historyId: 0} }, { upsert: true }) 88 | .then(() => true).catch((e) => (error(e), false)); 89 | } 90 | 91 | export async function DeleteUser(tgId: IUser["telegramID"]) { 92 | return User.deleteOne({ telegramID: tgId }) 93 | .then((res) => { 94 | return res.ok === 1; 95 | }) 96 | .catch((e: Error) => { 97 | error(e); 98 | return false; 99 | }); 100 | } 101 | -------------------------------------------------------------------------------- /src/gmail/pushUpdates.ts: -------------------------------------------------------------------------------- 1 | import Express, { Application } from "express"; 2 | import bodyParser from "body-parser"; 3 | // import mongoSanitize from "express-mongo-sanitize"; 4 | import { OAuth2Client } from "google-auth-library"; 5 | import { error, info } from "@service/logging"; 6 | import { getEmails, IMailObject, authorizeUser, watchMails } from "@gmail/index"; 7 | import { FindUserByEmail, FindAll, SetChatsId } from "@controller/user"; 8 | import { bot } from "@telegram/index"; 9 | import { getValue, setValue, isValueSet } from "@server/serverMap"; 10 | 11 | const jsonBodyParser = bodyParser.json(); 12 | const authClient = new OAuth2Client(); 13 | export const router = Express.Router(); 14 | 15 | router.post(process.env.GAPPS_PUSH_PATH, jsonBodyParser, async (req, res) => { 16 | try { 17 | const bearer = req.header("Authorization"); 18 | const [, token] = bearer.match(/Bearer (.*)/); 19 | await authClient.verifyIdToken({ 20 | idToken: token, 21 | audience: process.env.SERVER_PATH.replace(/https?:\/\/|\//g, ""), 22 | }); 23 | } catch (e) { 24 | error(e); 25 | res.status(400).send("Invalid token"); 26 | return; 27 | } 28 | const message = Buffer.from(req.body.message.data, "base64").toString("utf-8"); 29 | const obj = JSON.parse(message); 30 | // const emailAddress = (mongoSanitize.sanitize(obj.emailAddress) as string) 31 | // .toLowerCase().trim(); 32 | const emailAddress = (obj.emailAddress as string) 33 | .toLowerCase().trim(); 34 | // const historyId = mongoSanitize.sanitize(obj.historyId); 35 | const historyId = obj.historyId; 36 | const app = req.app; 37 | if (!addGmailUserWithHistoryId(app, emailAddress, historyId)) { 38 | info("This update was skipped due to it has been already processed"); 39 | res.status(204).send(); 40 | return; 41 | } 42 | const user = await FindUserByEmail(emailAddress); 43 | if (user) { 44 | let response: false | IMailObject[]; 45 | try { 46 | response = await getEmails(emailAddress, historyId); 47 | if (response === false) { 48 | throw new Error(); 49 | } 50 | } catch (e) { 51 | error(e); 52 | res.status(204).send(); 53 | return; 54 | } 55 | for (const chatId of user.chatsId) { 56 | for (const x of response) { 57 | if (!x.message) { 58 | error(new Error("empty message")); 59 | continue; 60 | } else { 61 | if (x.message.length > 3500) { 62 | // TODO send several messages 63 | x.message = x.message.substr(0, 3500); 64 | x.message = x.message + "\nMessage exceed max length"; 65 | } 66 | try { 67 | const sent = await bot.telegram.sendMessage( 68 | chatId, 69 | x.message, 70 | { disable_web_page_preview: true } 71 | ); 72 | x.attachments.forEach((y) => { 73 | bot.telegram.sendDocument( 74 | chatId, 75 | { filename: y.name, source: y.data }, 76 | { reply_to_message_id: sent.message_id } 77 | ); 78 | }); 79 | } catch (err) { 80 | try { 81 | try { 82 | const temp = await bot.telegram.getChat(chatId); 83 | const botID = (await bot.telegram.getMe()).id; 84 | let needToDel = false; 85 | if (temp.type !== "private") { 86 | needToDel = true; 87 | const admins = await bot.telegram.getChatAdministrators(chatId); 88 | const isUserAdmin = admins.some((y) => y.user.id === user.telegramID); 89 | const isBotAdmin = admins.some((y) => y.user.id === botID); 90 | if (isBotAdmin && isUserAdmin) { 91 | needToDel = false; 92 | } 93 | } 94 | } catch (e) { 95 | await SetChatsId(user.telegramID, user.chatsId.filter(i => i != chatId)); 96 | console.log("deleted chatID"); 97 | } 98 | } catch (e) { 99 | console.log("error while deleting caht id"); 100 | } 101 | console.log("error with sending, deleted chat id"); 102 | // console.log(err); 103 | } 104 | } 105 | } 106 | } 107 | } 108 | res.status(204).send(); 109 | cleanGmailHistoryIdMap(app); 110 | }); 111 | 112 | router.get(process.env.UPDATE_PUB_SUB_TOPIC_PATH, async (_req, res) => { 113 | const users = await FindAll(); 114 | if (!Array.isArray(users)) { 115 | res.status(204).send(); 116 | return; 117 | } 118 | for (const user of users) { 119 | const obj = await authorizeUser(user.telegramID); 120 | const tgId = user.telegramID.toString(); 121 | if (obj !== null) { 122 | if (obj.authorized) { 123 | if (!(await watchMails(user.telegramID, obj.oauth))) { 124 | error(new Error("couldn't watch mails")); 125 | bot.telegram.sendMessage(tgId, "Try to renew gmail subscription"); 126 | } else { 127 | info(`Successfully update subscription for ${tgId}`); 128 | } 129 | } else { 130 | error(new Error("bad token, not authorized")); 131 | bot.telegram.sendMessage(tgId, "Renew gmail subscription"); 132 | } 133 | } 134 | } 135 | res.status(204).send(); 136 | }); 137 | 138 | 139 | const emailHistoryIdMapKey = "emailHistoryIdMap"; 140 | 141 | function addGmailUserWithHistoryId(app: Application, email: string, histryId: number) { 142 | if (!isValueSet(app, emailHistoryIdMapKey)) { 143 | setValue(app, emailHistoryIdMapKey, new Map()); 144 | } 145 | const current = email + histryId.toString(); 146 | const curTime = new Date().getTime(); 147 | const mapGmailUserWithHistoryId = getValue>(app, emailHistoryIdMapKey); 148 | if (mapGmailUserWithHistoryId.has(current)) { 149 | return false; 150 | } else { 151 | mapGmailUserWithHistoryId.set(current, curTime); 152 | return true; 153 | } 154 | } 155 | 156 | function cleanGmailHistoryIdMap(app: Application) { 157 | if (isValueSet(app, emailHistoryIdMapKey)) { 158 | const map = getValue>(app, emailHistoryIdMapKey); 159 | if (map.size > 25) { 160 | const keysToDelete = Array.from(map.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10); 161 | keysToDelete.forEach(x => map.delete(x[0])); 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/gmail/index.ts: -------------------------------------------------------------------------------- 1 | import { FindUserById, SetToken, FindUserByEmail, SetHistoryId } from "@controller/user"; 2 | import { OAuth2Client } from "google-auth-library"; 3 | import Express from "express"; 4 | import { google, gmail_v1 } from "googleapis"; 5 | import { router as pushUpdatesRouter } from "@gmail/pushUpdates"; 6 | import { error } from "@service/logging"; 7 | import { GaxiosPromise } from "gaxios"; 8 | import htmlToText from "html-to-text"; 9 | import { toFormatedString } from "@service/date"; 10 | import { IUser } from "@model/user"; 11 | 12 | export const router = Express.Router(); 13 | 14 | router.use(pushUpdatesRouter); 15 | 16 | const SCOPES = [ "https://www.googleapis.com/auth/gmail.readonly" ]; 17 | 18 | export interface IAuthObject { oauth: OAuth2Client; authorized: boolean; } 19 | 20 | export interface IMailObject { message: string; attachments: IAttachmentObject[]; } 21 | 22 | export interface IAttachmentObject { name: string; data: Buffer; } 23 | 24 | export async function authorizeUser(tgID: number): Promise { 25 | const credentials = JSON.parse(process.env.GOOGLE_CREDENTIALS); 26 | const { client_secret, client_id, redirect_uris } = credentials.installed; 27 | const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]); 28 | const user = await FindUserById(tgID); 29 | if (!user) { 30 | return null; 31 | } 32 | try { 33 | if (user.token === " ") { 34 | return { oauth: oAuth2Client, authorized: false }; 35 | } else { 36 | oAuth2Client.setCredentials(JSON.parse(user.token)); 37 | return { oauth: oAuth2Client, authorized: true }; 38 | } 39 | } catch (e) { 40 | error(e); 41 | return null; 42 | } 43 | } 44 | 45 | export function generateUrlToGetToken(oAuth2Client: OAuth2Client) { 46 | return oAuth2Client.generateAuthUrl({ 47 | access_type: "offline", 48 | scope: SCOPES, 49 | }); 50 | } 51 | 52 | export async function getNewToken( 53 | tgID: number, 54 | oAuth2Client: OAuth2Client, 55 | code: string 56 | ): Promise { 57 | return new Promise((resolve) => { 58 | oAuth2Client.getToken(code, async (err, token) => { 59 | if (err) { 60 | error(err); 61 | return resolve(null); 62 | } 63 | oAuth2Client.setCredentials(token); 64 | try { 65 | if (!(await SetToken(tgID, JSON.stringify(token)))) { 66 | throw new Error("Couldn't write token"); 67 | } 68 | resolve(oAuth2Client); 69 | } catch (err) { 70 | resolve(null); 71 | } 72 | }); 73 | }); 74 | } 75 | 76 | export async function getEmailAdress(auth: OAuth2Client) { 77 | const gmail = google.gmail({ version: "v1", auth }); 78 | let res; 79 | try { 80 | res = await gmail.users.getProfile({ userId: "me" }); 81 | } catch (e) { 82 | error(e); 83 | return false; 84 | } 85 | if (res.status !== 200) { 86 | return false; 87 | } 88 | return res.data.emailAddress; 89 | } 90 | 91 | export async function stopNotifications(auth: OAuth2Client) { 92 | const gmail = google.gmail({ version: "v1", auth }); 93 | let res; 94 | try { 95 | res = await gmail.users.stop({ 96 | userId: "me" 97 | }); 98 | } catch (e) { 99 | error(e); 100 | return false; 101 | } 102 | console.log(res); 103 | if (res.status !== 200 && res.status !== 204) { 104 | return false; 105 | } 106 | return true; 107 | } 108 | 109 | export async function watchMails(tgId: IUser["telegramID"], auth: OAuth2Client) { 110 | const gmail = google.gmail({ version: "v1", auth }); 111 | let res; 112 | try { 113 | res = await gmail.users.watch({ 114 | userId: "me", 115 | requestBody: { 116 | topicName: process.env.PUB_SUB_TOPIC, 117 | labelIds: ["INBOX"] 118 | } 119 | }); 120 | } catch (e) { 121 | error(e); 122 | return false; 123 | } 124 | console.log(res); 125 | if (res.status !== 200) { 126 | return false; 127 | } 128 | const utcMs = Number.parseInt(res.data.expiration, 10); 129 | const date = new Date(utcMs); 130 | console.log(date); 131 | const hId = Number.parseInt(res.data.historyId, 10); 132 | if (!(await SetHistoryId(tgId, hId))) { 133 | return false; 134 | } 135 | return true; 136 | } 137 | 138 | export async function getEmails(emailAdress: string, historyId: number): Promise { 139 | const user = await FindUserByEmail(emailAdress); 140 | if (!user) { 141 | return false; 142 | } 143 | const credentials = JSON.parse(process.env.GOOGLE_CREDENTIALS); 144 | const { client_secret, client_id, redirect_uris } = credentials.installed; 145 | const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]); 146 | if (user.token === " ") { 147 | error(new Error("Bad token")); 148 | return false; 149 | } 150 | oAuth2Client.setCredentials(JSON.parse(user.token)); 151 | const gmail = google.gmail({ version: "v1", auth: oAuth2Client }); 152 | let res; 153 | try { 154 | res = await asyncListHistory(gmail, user.historyId); 155 | } catch (e) { 156 | error(e); 157 | return false; 158 | } 159 | const emailsId: string[] = []; 160 | for (const r of res) { 161 | if (r.messagesAdded) { 162 | r.messagesAdded.forEach((mail) => { 163 | emailsId.push(mail.message.id); 164 | }); 165 | } 166 | } 167 | const messagesDocuments = await retriveEmailsFromIds(gmail, emailsId); 168 | if (!messagesDocuments) { 169 | return false; 170 | } 171 | const result = []; 172 | for (const mail of messagesDocuments) { 173 | let message = ""; 174 | if (mail.payload.parts) { 175 | let data = mail.payload.parts.filter((x) => x.mimeType.includes("text/html")); 176 | if (data.length === 0) { 177 | for (const part of mail.payload.parts) { 178 | if (part.parts) { 179 | data = data.concat(part.parts.filter((x) => x.mimeType.includes("text/html"))); 180 | } 181 | } 182 | } 183 | message = data.reduce((prev, cur) => prev += base64ToString(cur.body.data), ""); 184 | message = htmlToText.fromString(message); 185 | 186 | // TODO 187 | if (message.trim().length === 0) { 188 | let data = mail.payload.parts.filter((x) => x.headers && x.headers.filter(x => x.name && x.name.includes("Content-Type") && x.value && x.value.includes("text/html")).length > 0); 189 | if (data.length === 0) { 190 | for (const part of mail.payload.parts) { 191 | if (part.parts) { 192 | data = data.concat(part.parts.filter((x) => x.headers && x.headers.filter(x => x.name && x.name.includes("Content-Type") && x.value && x.value.includes("text/html")).length > 0)); 193 | } 194 | } 195 | } 196 | message = data.reduce((prev, cur) => prev += base64ToString(cur.body.data), ""); // 197 | message = htmlToText.fromString(message); // 198 | } 199 | // TODO 200 | } else if (mail.payload.body) { 201 | message = htmlToText.fromString(base64ToString(mail.payload.body.data || "")); 202 | } 203 | if (mail.payload.headers) { 204 | const date = mail.payload.headers.filter((x) => x.name === "Date"); 205 | const from = mail.payload.headers.filter((x) => x.name === "From"); 206 | const subject = mail.payload.headers.filter((x) => x.name === "Subject"); 207 | if (subject[0]) { 208 | message = `Subject: ${subject[0].value}\n\n\n\n` + message; 209 | } 210 | if (date[0]) { 211 | const dateVal = new Date(date[0].value); 212 | message = `Date: ${toFormatedString(dateVal)}\n` + message; 213 | } 214 | if (from[0]) { 215 | const fromValue = from[0].value.toLowerCase(); 216 | if (fromValue.includes(emailAdress) || shouldSkipEmailFromThisSender(fromValue, user)) { 217 | continue; 218 | } 219 | message = `From: ${fromValue}\n` + message; 220 | } 221 | } 222 | const attachments: IAttachmentObject[] = []; 223 | if (mail.payload && mail.payload.parts) { 224 | for (const part of mail.payload.parts) { 225 | if (part.filename) { 226 | if (part.body.data) { 227 | const data = Buffer.from(part.body.data, "base64"); 228 | attachments.push({ name: part.filename, data }); 229 | } else { 230 | const attId = part.body.attachmentId; 231 | const attachment = await retriveAttachment(gmail, mail.id, attId); 232 | if (!attachment) { 233 | return false; 234 | } 235 | const data = Buffer.from(attachment.data, "base64"); 236 | attachments.push({ name: part.filename, data }); 237 | } 238 | } 239 | } 240 | } 241 | result.push({ message, attachments }); 242 | } 243 | if (!(await SetHistoryId(user.telegramID, historyId))) { 244 | return false; 245 | } 246 | return result; 247 | } 248 | 249 | function base64ToString(x: string) { 250 | if (typeof x !== "string") { 251 | return ""; 252 | } 253 | return Buffer.from(x, "base64").toString("utf-8"); 254 | } 255 | 256 | async function retriveAttachment(gmail: gmail_v1.Gmail, messageId: string, attId: string) { 257 | let resp; 258 | try { 259 | resp = await gmail.users.messages.attachments.get({ userId: "me", messageId, id: attId }); 260 | if (resp.status !== 200) { 261 | throw new Error(resp.statusText); 262 | } 263 | } catch (e) { 264 | error(e); 265 | return false; 266 | } 267 | return resp.data; 268 | } 269 | 270 | async function retriveEmailsFromIds(gmail: gmail_v1.Gmail, arr: string[]) { 271 | const result = []; 272 | for (const id of arr) { 273 | let resp; 274 | try { 275 | resp = await gmail.users.messages.get({ userId: "me", id, format: "FULL" }); 276 | } catch (e) { 277 | error(e); 278 | continue; 279 | } 280 | if (resp.status === 404) { 281 | continue; 282 | } else if (resp.status !== 200) { 283 | console.log(resp.status); 284 | error(new Error(resp.statusText)); 285 | continue; 286 | } 287 | result.push(resp.data); 288 | } 289 | return result; 290 | } 291 | 292 | async function asyncListHistory(gmail: gmail_v1.Gmail, startHistoryId: number) { 293 | return new Promise((resolve, reject) => { 294 | listHistory(gmail, startHistoryId, (res, err) => err ? reject(err) : resolve(res)); 295 | }); 296 | } 297 | 298 | function listHistory( 299 | gmail: gmail_v1.Gmail, 300 | startHistoryId: number, 301 | callback: (res: gmail_v1.Schema$History[], err: Error) => void 302 | ) { 303 | const getPageOfHistory = function( 304 | request: GaxiosPromise, 305 | result: gmail_v1.Schema$History[] 306 | ) { 307 | request.then(function(resp) { 308 | if (resp.status !== 200) { 309 | callback(null, new Error(resp.statusText)); 310 | } 311 | result = result.concat(resp.data.history || []); 312 | const nextPageToken = resp.data.nextPageToken; 313 | if (nextPageToken) { 314 | request = gmail.users.history.list({ 315 | "userId": "me", 316 | "labelId": "INBOX", 317 | "startHistoryId": startHistoryId.toString(), 318 | "pageToken": nextPageToken 319 | }); 320 | getPageOfHistory(request, result); 321 | } else { 322 | callback(result, null); 323 | } 324 | }).catch(err => { 325 | console.error(`Error in listen history callback: ${err}`); 326 | callback(null, new Error(err)); 327 | }); 328 | }; 329 | const req = gmail.users.history.list({ 330 | "userId": "me", 331 | "labelId": "INBOX", 332 | "startHistoryId": startHistoryId.toString() 333 | }); 334 | getPageOfHistory(req, []); 335 | } 336 | 337 | function shouldSkipEmailFromThisSender(valueWithSenderEmailAddress: string, currentTgUser: IUser) { 338 | if (typeof currentTgUser.filterActionIsBlock === "boolean" && Array.isArray(currentTgUser.senderEmailsToFilter)) { 339 | if (currentTgUser.filterActionIsBlock) { 340 | return currentTgUser.senderEmailsToFilter.some(x => valueWithSenderEmailAddress.includes(x)); 341 | } else { 342 | return currentTgUser.senderEmailsToFilter.every(x => !valueWithSenderEmailAddress.includes(x)); 343 | } 344 | } 345 | return false; 346 | } 347 | --------------------------------------------------------------------------------