UNPKG

@cocalc/hub

Version:
809 lines (790 loc) 29.1 kB
"use strict"; /* * This file is part of CoCalc: Copyright © 2020 Sagemath, Inc. * License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.email_verification_problem = exports.email_verified_successfully = exports.welcome_email = exports.mass_email = exports.send_email = exports.is_banned = exports.send_invite_email = exports.escape_email_body = void 0; //######################################## // Sending emails //######################################## const BANNED_DOMAINS = { "qq.com": true }; const util_1 = require("util"); const fs = __importStar(require("fs")); const os_path = __importStar(require("path")); const lodash_1 = require("lodash"); const fs_readFile_prom = (0, util_1.promisify)(fs.readFile); const logger_1 = require("./logger"); const lodash_2 = require("lodash"); const site_defaults_1 = require("@cocalc/util/db-schema/site-defaults"); const base_path_1 = __importDefault(require("@cocalc/backend/base-path")); const data_1 = require("@cocalc/backend/data"); // sendgrid API: https://sendgrid.com/docs/API_Reference/Web_API/mail.html const client_1 = __importDefault(require("@sendgrid/client")); const nodemailer_1 = require("nodemailer"); const misc_1 = require("@cocalc/util/misc"); const site_defaults_2 = require("@cocalc/util/db-schema/site-defaults"); const sanitize_html_1 = __importDefault(require("sanitize-html")); const misc_2 = require("@cocalc/backend/misc"); const theme_1 = require("@cocalc/util/theme"); const async = __importStar(require("async")); const winston = (0, logger_1.getLogger)("email"); function escape_email_body(body, allow_urls) { // in particular, no img and no anchor a const allowedTags = [ "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "p", "ul", "ol", "nl", "li", "b", "i", "strong", "em", "strike", "code", "hr", "br", "div", "table", "thead", "caption", "tbody", "tr", "th", "td", "pre", ]; if (allow_urls) { allowedTags.push("a"); } return (0, sanitize_html_1.default)(body, { allowedTags }); } exports.escape_email_body = escape_email_body; function fallback(val, alt) { if (typeof val == "string" && val.length > 0) { return val; } else { return alt; } } // global state let sendgrid_server = undefined; let sendgrid_server_disabled = false; let smtp_server = undefined; let smtp_server_created = undefined; // timestamp let smtp_server_conf = undefined; let smtp_pw_reset_server = undefined; async function init_sendgrid(opts, dbg) { if (sendgrid_server != null) { return; } dbg("sendgrid not configured, starting..."); try { // settings.sendgrid_key takes precedence over a local config file let api_key = ""; const ssgk = opts.settings.sendgrid_key; if (typeof ssgk == "string" && ssgk.trim().length > 0) { dbg("... using site settings/sendgrid_key"); api_key = ssgk.trim(); } else { const filename = os_path.join(data_1.secrets, "sendgrid"); try { api_key = await fs_readFile_prom(filename, "utf8"); api_key = api_key.toString().trim(); dbg(`... using sendgrid_key stored in ${filename}`); } catch (err) { throw new Error(`unable to read the file '${filename}', which is needed to send emails -- ${err}`); dbg(err); } } if (api_key.length === 0) { dbg("sendgrid_server: explicitly disabled -- so pretend to always succeed for testing purposes"); sendgrid_server_disabled = true; } else { client_1.default.setApiKey(api_key); sendgrid_server = client_1.default; dbg("started sendgrid client"); } } catch (err) { dbg(`Problem initializing Sendgrid -- ${err}`); } } async function init_smtp_server(opts, dbg) { const s = opts.settings; const conf = { host: s.email_smtp_server, port: s.email_smtp_port, secure: s.email_smtp_secure, auth: { user: s.email_smtp_login, pass: s.email_smtp_password, }, }; // we check, if we can keep the smtp server instance if (smtp_server != null && smtp_server_conf != null && s._timestamp != null && smtp_server_created != null) { if (smtp_server_created < s._timestamp) { if (!(0, lodash_1.isEqual)(smtp_server_conf, conf)) { dbg("SMTP server instance outdated, recreating"); } else { // settings changed, but the server config is the same smtp_server_created = Date.now(); return; } } else { return; } } dbg("SMTP server not configured. setting up ..."); smtp_server = await (0, nodemailer_1.createTransport)(conf); smtp_server_created = Date.now(); smtp_server_conf = conf; dbg("SMTP server configured"); } async function send_via_smtp(opts, dbg) { dbg("sending email via SMTP backend"); const msg = { from: opts.from, to: opts.to, subject: opts.subject, html: smtp_email_body(opts), }; if (opts.replyto) { msg.replyTo = opts.replyto; } if (opts.cc != null && opts.cc.length > 0) { msg.cc = opts.cc; } if (opts.bcc != null && opts.bcc.length > 0) { msg.bcc = opts.bcc; } const info = await smtp_server.sendMail(msg); dbg(`sending email via SMTP succeeded -- message id='${info.messageId}'`); return info.messageId; } async function send_via_sendgrid(opts, dbg) { dbg(`sending email to ${opts.to} starting...`); // Sendgrid V3 API -- https://sendgrid.com/docs/API_Reference/Web_API_v3/Mail/index.html // no "to" field, that's in "personalizations!" const msg = { from: { email: opts.from, name: opts.fromname }, subject: opts.subject, content: [ { type: "text/html", value: opts.body, }, ], // plain template with a header (cocalc logo), a h1 title, and a footer template_id: theme_1.SENDGRID_TEMPLATE_ID, personalizations: [ { subject: opts.subject, to: [ { email: opts.to, }, ], }, ], // This #title# will end up below the header in an <h1> according to the template substitutions: { "#title#": opts.subject, }, }; if (opts.replyto) { msg.reply_to = { name: opts.replyto_name ?? opts.replyto, email: opts.replyto, }; } if (opts.cc != null && opts.cc.length > 0) { msg.cc = [{ email: opts.cc }]; } if (opts.bcc != null && opts.bcc.length > 0) { msg.bcc = [{ email: opts.bcc }]; } // one or more strings to categorize the sent emails on sendgrid if (opts.category != null) { if (typeof opts.category == "string") { msg.categories = [opts.category]; } else if (Array.isArray(opts.category)) { msg.categories = opts.category; } } // to unsubscribe only from a specific type of email, not everything! // https://app.sendgrid.com/suppressions/advanced_suppression_manager if (opts.asm_group != null) { msg.asm = { group_id: opts.asm_group }; } dbg(`sending email to ${opts.to} -- data -- ${(0, misc_1.to_json)(msg)}`); const req = { body: msg, method: "POST", url: "/v3/mail/send", }; return new Promise((done, fail) => { sendgrid_server .request(req) .then(([_, body]) => { dbg(`sending email to ${opts.to} -- success -- ${(0, misc_1.to_json)(body)}`); done(); }) .catch((err) => { dbg(`sending email to ${opts.to} -- error = ${(0, misc_1.to_json)(err)}`); fail(err); }); }); } // constructs the email body for INVITES! (collaborator and student course) // this includes sign up instructions pointing to the given project // it might throw an error! function create_email_body(subject, body, email_address, project_title, link2proj, allow_urls_in_emails) { let direct_link; let base_url; if (link2proj != null) { const base_url_segments = link2proj.split("/"); base_url = `${base_url_segments[0]}//${base_url_segments[2]}`; direct_link = `Open <a href='${link2proj}'>the project '${project_title}'</a>.`; } else { // no link2proj provided -- show something useful: direct_link = ""; base_url = "https://cocalc.com"; } let email_body = ""; if (body) { email_body = escape_email_body(body, allow_urls_in_emails); // we check if there are plain URLs, which can be used to send SPAM if (!allow_urls_in_emails && (0, misc_2.contains_url)(email_body)) { throw new Error("Sorry, links to specific websites are not allowed!"); } } else { email_body = subject; } email_body += ` <br/><br/> <b>To accept the invitation: <ol> <li>Open <a href="${base_url}/app">CoCalc</a></li> <li>Sign up/in using <i>exactly</i> your email address <code>${email_address}</code></li> <li>${direct_link}</li> </ol></b> <br/><br /> (If you're already signed in via <i>another</i> email address, you have to sign out and sign up/in using the mentioned email address.) `; return email_body; } function send_invite_email(opts) { try { const email_body = create_email_body(opts.subject, opts.email, opts.email_address, opts.title, opts.link2proj, opts.allow_urls); send_email({ to: opts.to, bcc: opts.settings.kucalc === site_defaults_1.KUCALC_COCALC_COM ? "invites@cocalc.com" : "", fromname: fallback(opts.settings.organization_name, theme_1.COMPANY_NAME), from: fallback(opts.settings.organization_email, theme_1.COMPANY_EMAIL), category: "invite", asm_group: theme_1.SENDGRID_ASM_INVITES, settings: opts.settings, subject: opts.subject, body: email_body, replyto: opts.replyto, replyto_name: opts.replyto_name, cb: opts.cb, }); } catch (err) { opts.cb(err); } } exports.send_invite_email = send_invite_email; function is_banned(address) { const i = address.indexOf("@"); if (i === -1) { return false; } const x = address.slice(i + 1).toLowerCase(); return !!BANNED_DOMAINS[x]; } exports.is_banned = is_banned; function make_dbg(opts) { if (opts.verbose) { return (m) => winston.debug(`send_email(to:${opts.to}) -- ${m}`); } else { return function (_) { }; } } async function init_pw_reset_smtp_server(opts) { const s = opts.settings; if (smtp_pw_reset_server != null) { return; } // s.password_reset_smtp_from; smtp_pw_reset_server = await (0, nodemailer_1.createTransport)({ host: s.password_reset_smtp_server, port: s.password_reset_smtp_port, secure: s.password_reset_smtp_secure, auth: { user: s.password_reset_smtp_login, pass: s.password_reset_smtp_password, }, }); } const smtp_footer = ` <p style="margin-top:150px; border-top: 1px solid gray; color: gray; font-size:85%; text-align:center"> This email was sent by <a href="<%= url %>"><%= settings.site_name %></a> by <%= company_name %>. Contact <a href="mailto:<%= settings.help_email %>"><%= settings.help_email %></a> if you have any questions. </p>`; // construct the actual HTML body of a password reset email sent via SMTP // in particular, all emails must have a body explaining who sent it! const pw_reset_body_tmpl = (0, lodash_2.template)(` <h2><%= subject %></h2> <%= body %> ${smtp_footer} `); function password_reset_body(opts) { return pw_reset_body_tmpl(opts); } const smtp_email_body_tmpl = (0, lodash_2.template)(` <%= body %> ${smtp_footer} `); // construct the email body for mails sent via smtp function smtp_email_body(opts) { return smtp_email_body_tmpl(opts); } const opts_default = { subject: misc_1.required, body: misc_1.required, fromname: undefined, from: undefined, to: misc_1.required, replyto: undefined, replyto_name: undefined, cc: "", bcc: "", verbose: true, cb: undefined, category: undefined, asm_group: undefined, settings: misc_1.required, }; // here's how I test this function: // require('email').send_email(subject:'TEST MESSAGE', body:'body', to:'wstein@sagemath.com', cb:console.log) async function send_email(opts) { const settings = opts.settings; const company_name = fallback(settings.organization_name, theme_1.COMPANY_NAME); opts_default.fromname = opts_default.fromname || company_name; opts_default.from = opts_default.from || settings.organization_email; opts = (0, misc_1.defaults)(opts, opts_default); opts.company_name = company_name; const dns = fallback(settings.dns, theme_1.DNS); opts.url = `https://${dns}`; const dbg = make_dbg(opts); dbg(`${opts.body.slice(0, 201)}...`); if (is_banned(opts.to) || is_banned(opts.from)) { dbg("WARNING: attempt to send banned email"); if (typeof opts.cb === "function") { opts.cb("banned domain"); } return; } // logic: // 0. email_enabled == false, don't send any emails, period. // 1. email_backend == none, can't send usual emails // == sendgrid | smtp → send using one of these // 2. password_reset_override == 'default', do what (1.) is set to // == 'smtp', override (1.), including "none" // an optional message to log and report back let message = undefined; if (opts.settings.email_enabled == false) { const x = site_defaults_2.site_settings_conf.email_enabled.name; message = `sending any emails is disabled -- see 'Admin/Site Settings/${x}'`; dbg(message); } const pw_reset_smtp = opts.category == "password_reset" && opts.settings.password_reset_override == "smtp"; const email_verify_smtp = opts.category == "verify" && opts.settings.password_reset_override == "smtp"; const email_backend = opts.settings.email_backend ?? "sendgrid"; try { // this is a password reset or email verification token email // and we send it via smtp because the override is enabled if (pw_reset_smtp || email_verify_smtp) { dbg("initializing PW SMTP server..."); await init_pw_reset_smtp_server(opts); const html = opts.category == "verify" ? opts.body : password_reset_body(opts); dbg(`sending email category=${opts.category} via SMTP server ...`); const info = await smtp_pw_reset_server.sendMail({ from: opts.settings.password_reset_smtp_from, replyTo: opts.settings.password_reset_smtp_from, to: opts.to, subject: opts.subject, html, }); message = `password reset email sent via SMTP: ${info.messageId}`; dbg(message); } else { // INIT phase await init_sendgrid(opts, dbg); await init_smtp_server(opts, dbg); // SEND phase switch (email_backend) { case "sendgrid": // if not available for any reason … if (sendgrid_server == null || sendgrid_server_disabled) { message = "sendgrid email is disabled -- no actual message sent"; dbg(message); } else { await send_via_sendgrid(opts, dbg); } break; case "smtp": await send_via_smtp(opts, dbg); break; case "none": message = "no email sent, because email_backend is 'none' -- configure it in 'Admin/Site Settings'"; dbg(message); break; } } // all fine, no errors typeof opts.cb === "function" ? opts.cb(undefined, message) : undefined; } catch (err) { if (err) { // so next time it will try fresh to connect to email server, rather than being wrecked forever. sendgrid_server = undefined; err = `error sending email -- ${(0, misc_1.to_json)(err)}`; dbg(err); } else { dbg("successfully sent email"); } typeof opts.cb === "function" ? opts.cb(err, message) : undefined; } } exports.send_email = send_email; // Send a mass email to every address in a file. // E.g., put the email addresses in a file named 'a' and // require('email').mass_email(subject:'TEST MESSAGE', body:'body', to:'a', cb:console.log) function mass_email(opts) { opts = (0, misc_1.defaults)(opts, { subject: misc_1.required, body: misc_1.required, from: theme_1.COMPANY_EMAIL, fromname: theme_1.COMPANY_NAME, to: misc_1.required, cc: "", limit: 10, cb: undefined, }); // cb(err, list of recipients that we succeeded in sending email to) const dbg = (m) => winston.debug(`mass_email: ${m}`); dbg(opts.filename); dbg(opts.subject); dbg(opts.body); const success = []; const recipients = []; return async.series([ function (cb) { if (typeof opts.to !== "string") { recipients.push(opts.to); cb(); } else { fs.readFile(opts.to, function (err, data) { if (err) { cb(err); } else { recipients.push(...(0, misc_1.split)(data.toString())); cb(); } }); } }, function (cb) { let n = 0; const f = function (to, cb) { if (n % 100 === 0) { dbg(`${n}/${recipients.length - 1}`); } n += 1; send_email({ subject: opts.subject, body: opts.body, from: opts.from, fromname: opts.fromname, to, cc: opts.cc, asm_group: theme_1.SENDGRID_ASM_NEWSLETTER, category: "newsletter", verbose: false, settings: {}, cb(err) { if (!err) { success.push(to); cb(); } else { cb(`error sending email to ${to} -- ${err}`); } }, }); }; async.mapLimit(recipients, opts.limit, f, cb); }, ], (err) => (typeof opts.cb === "function" ? opts.cb(err, success) : undefined)); } exports.mass_email = mass_email; function verify_email_html(token_url) { return ` <p style="margin-top:0;margin-bottom:20px;"> <strong> Please <a href="${token_url}">click here</a> to verify your email address! </strong> </p> <p style="margin-top:0;margin-bottom:20px;"> If this link does not work, please copy/paste this URL into a new browser tab and open the link: </p> <pre style="margin-top:10px;margin-bottom:10px;font-size:11px;"> ${token_url} </pre> `; } // beware, this needs to be HTML which is compatible with email-clients! function welcome_email_html({ token_url, verify_emails, site_name, url }) { return `\ <h1>Welcome to ${site_name}</h1> <p style="margin-top:0;margin-bottom:10px;"> <a href="${url}">${site_name}</a> helps you to work with open-source scientific software in your web browser. </p> <p style="margin-top:0;margin-bottom:20px;"> You received this email because an account with your email address was created. This was either initiated by you, a friend or colleague invited you, or you're a student as part of a course. </p> ${verify_emails ? verify_email_html(token_url) : ""} <hr size="1"/> <h3>Exploring ${site_name}</h3> <p style="margin-top:0;margin-bottom:10px;"> In ${site_name} your work happens inside <strong>private projects</strong>. These are personal workspaces which contain your files, computational worksheets, and data. You can run your computations through the web interface, via interactive worksheets and notebooks, or by executing a program in a terminal. ${site_name} supports online editing of <a href="https://jupyter.org/">Jupyter Notebooks</a>, <a href="https://www.sagemath.org/">Sage Worksheets</a>, <a href="https://en.wikibooks.org/wiki/LaTeX">Latex files</a>, etc. </p> <p style="margin-top:0;margin-bottom:10px;"> <strong>How to get from 0 to 100:</strong> </p> <ul> <li style="margin-top:0;margin-bottom:10px;"> <strong><a href="https://doc.cocalc.com/">CoCalc Manual:</a></strong> learn more about CoCalc's features. </li> <li style="margin-top:0;margin-bottom:10px;"> <a href="https://doc.cocalc.com/jupyter.html">Working with Jupyter Notebooks</a> </li> <li style="margin-top:0;margin-bottom:10px;"> <a href="https://doc.cocalc.com/sagews.html">Working with SageMath Worksheets</a> </li> <li style="margin-top:0;margin-bottom:10px;"> <strong><a href="https://cocalc.com/policies/pricing.html">Subscriptions:</a></strong> make hosting more robust and increase project quotas </li> <li style="margin-top:0;margin-bottom:10px;"> <a href="https://doc.cocalc.com/teaching-instructors.html">Teaching a course on CoCalc</a>. </li> <li style="margin-top:0;margin-bottom:10px;"> <a href="https://doc.cocalc.com/howto/connectivity-issues.html">Troubleshooting connectivity issues</a> </li> <li style="margin-top:0;margin-bottom:10px;"> <a href="https://github.com/sagemathinc/cocalc/wiki/MathematicalSyntaxErrors">Common mathematical syntax errors:</a> look into this if you are new to working with a programming language! </li> </ul> <p style="margin-top:0;margin-bottom:20px;"> <strong>Collaboration:</strong> You can invite collaborators to work with you inside a project. Like you, they can edit the files in that project. Edits are visible in <strong>real time</strong> for everyone online. You can share your thoughts in a <strong>side chat</strong> next to each document. </p> <p><strong>Software:</strong> <ul> <li style="margin-top:0;margin-bottom:10px;">Mathematical calculation: <a href="https://www.sagemath.org/">SageMath</a>, <a href="https://www.sympy.org/">SymPy</a>, etc. </li> <li style="margin-top:0;margin-bottom:10px;">Statistics and Data Science: <a href="https://www.r-project.org/">R project</a>, <a href="http://pandas.pydata.org/">Pandas</a>, <a href="http://www.statsmodels.org/">statsmodels</a>, <a href="http://scikit-learn.org/">scikit-learn</a>, <a href="http://www.nltk.org/">NLTK</a>, etc. </li> <li style="margin-top:0;margin-bottom:10px;">Various other computation: <a href="https://www.tensorflow.org/">Tensorflow</a>, <a href="https://www.gnu.org/software/octave/">Octave</a>, <a href="https://julialang.org/">Julia</a>, etc. </li> </ul> <p style="margin-top:0;margin-bottom:20px;"> Visit our <a href="https://cocalc.com/doc/software.html">Software overview page</a> for more details! </p> <p style="margin-top:20px;margin-bottom:10px;"> <strong>Questions?</strong> </p> <p style="margin-top:0;margin-bottom:10px;"> Schedule a Live Demo with a specialist from CoCalc: <a href="${theme_1.LIVE_DEMO_REQUEST}">request form</a>. </p> <p style="margin-top:0;margin-bottom:20px;"> In case of problems, concerns why you received this email, or other questions please contact: <a href="mailto:${theme_1.HELP_EMAIL}">${theme_1.HELP_EMAIL}</a>. </p> \ `; } function welcome_email(opts) { let body, category, subject; opts = (0, misc_1.defaults)(opts, { to: misc_1.required, token: misc_1.required, only_verify: false, settings: misc_1.required, cb: undefined, }); if (opts.to == null) { // users can sign up without an email address. ignore this. typeof opts.cb === "function" ? opts.cb(undefined) : undefined; return; } const settings = opts.settings; const site_name = fallback(settings.site_name, theme_1.SITE_NAME); const dns = fallback(settings.dns, theme_1.DNS); const url = `https://${dns}`; const token_query = encodeURI(`email=${encodeURIComponent(opts.to)}&token=${opts.token}`); const endpoint = os_path.join(base_path_1.default, "auth", "verify"); const token_url = `${url}${endpoint}?${token_query}`; const verify_emails = opts.settings.verify_emails ?? true; if (opts.only_verify) { // only send the verification email, if settings.verify_emails is true if (!verify_emails) return; subject = `Verify your email address on ${site_name} (${dns})`; body = verify_email_html(token_url); category = "verify"; } else { subject = `Welcome to ${site_name} - ${dns}`; body = welcome_email_html({ token_url, verify_emails, site_name, url }); category = "welcome"; } send_email({ subject, body, fromname: fallback(settings.organization_name, theme_1.COMPANY_NAME), from: fallback(settings.organization_email, theme_1.COMPANY_EMAIL), to: opts.to, cb: opts.cb, category, settings: opts.settings, asm_group: 147985, }); // https://app.sendgrid.com/suppressions/advanced_suppression_manager } exports.welcome_email = welcome_email; function email_verified_successfully(url) { const title = `${theme_1.SITE_NAME}: Email verification successful`; return `<DOCTYPE html> <html> <head> <meta http-equiv="refresh" content="5;url=${url}" /> <style> * {font-family: sans-serif;} </style> <title>${title}</title> </head> <body> <h1>Email verification successful!</h1> <div> Click <a href="${url}">here</a> if you aren't automatically redirected to <a href="${url}">${theme_1.SITE_NAME}</a> within 30 seconds. </div> </body> </html> `; } exports.email_verified_successfully = email_verified_successfully; function email_verification_problem(url, problem) { const title = `${theme_1.SITE_NAME}: Email verification problem`; return `<DOCTYPE html> <html> <head> <style> div, p, h1, h2 {font-family: sans-serif;} div {margin-top: 1rem;} </style> <title>${title}</title> </head> <body> <h1>${title}</h1> <div>There was a problem verifying your email address.</div> <div>Reason: <code>${problem}</code></div> <div> Continue to <a href="${url}">${theme_1.SITE_NAME}</a> or contact support: <a href="mailto:${theme_1.HELP_EMAIL}">${theme_1.HELP_EMAIL}</a>. </div> </body> </html> `; } exports.email_verification_problem = email_verification_problem; //# sourceMappingURL=email.js.map