diff --git a/src/core/client/admin/routeConfig.tsx b/src/core/client/admin/routeConfig.tsx index 53d65b4e5..cafbc2cc4 100644 --- a/src/core/client/admin/routeConfig.tsx +++ b/src/core/client/admin/routeConfig.tsx @@ -10,6 +10,7 @@ import ConfigureRoute from "./routes/Configure"; import { AdvancedConfigRoute, AuthConfigRoute, + EmailConfigRoute, GeneralConfigRoute, ModerationConfigRoute, OrganizationConfigRoute, @@ -71,6 +72,7 @@ export default makeRouteConfig( + diff --git a/src/core/client/admin/routes/Configure/Configure.tsx b/src/core/client/admin/routes/Configure/Configure.tsx index a15afc464..407479d99 100644 --- a/src/core/client/admin/routes/Configure/Configure.tsx +++ b/src/core/client/admin/routes/Configure/Configure.tsx @@ -49,6 +49,9 @@ const Configure: FunctionComponent = ({ Authentication + + Email + Advanced diff --git a/src/core/client/admin/routes/Configure/sections/Email/EmailConfigContainer.css b/src/core/client/admin/routes/Configure/sections/Email/EmailConfigContainer.css new file mode 100644 index 000000000..27621d11d --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Email/EmailConfigContainer.css @@ -0,0 +1,3 @@ +.title { + display: flex; +} diff --git a/src/core/client/admin/routes/Configure/sections/Email/EmailConfigContainer.tsx b/src/core/client/admin/routes/Configure/sections/Email/EmailConfigContainer.tsx new file mode 100644 index 000000000..fb5f5c447 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Email/EmailConfigContainer.tsx @@ -0,0 +1,123 @@ +import { FormApi } from "final-form"; +import { Localized } from "fluent-react/compat"; +import { RouteProps } from "found"; +import React from "react"; +import { Field } from "react-final-form"; +import { graphql } from "react-relay"; + +import { EmailConfigContainer_email } from "coral-admin/__generated__/EmailConfigContainer_email.graphql"; +import { DeepNullable, DeepPartial } from "coral-common/types"; +import { pureMerge } from "coral-common/utils"; +import { parseBool } from "coral-framework/lib/form"; +import { withFragmentContainer } from "coral-framework/lib/relay"; +import { GQLEmailConfiguration } from "coral-framework/schema"; +import { + CheckBox, + Flex, + FormField, + HorizontalGutter, +} from "coral-ui/components"; + +import Header from "../../Header"; +import FromContainer from "./FromContainer"; +import SMTPContainer from "./SMTPContainer"; + +import styles from "./EmailConfigContainer.css"; + +interface Props { + form: FormApi; + submitting: boolean; + email: EmailConfigContainer_email; +} + +export type FormProps = DeepNullable; +export type OnInitValuesFct = (values: DeepPartial) => void; + +class EmailConfigContainer extends React.Component { + public static routeConfig: RouteProps; + private initialValues: DeepPartial = {}; + + public componentDidMount() { + this.props.form.initialize({ email: this.initialValues }); + } + + private handleOnInitValues: OnInitValuesFct = values => { + if (values.smtp && values.smtp.authentication === null) { + values = { ...values, smtp: { ...values.smtp, authentication: true } }; + } + if (values.smtp && values.smtp.secure === null) { + values = { ...values, smtp: { ...values.smtp, secure: true } }; + } + + this.initialValues = pureMerge>( + this.initialValues, + values + ); + }; + + public render() { + const { email, submitting } = this.props; + + return ( + + + {({ input }) => ( +
} + > +
+ + Email settings + +
+
+ + + + Enabled + + + +
+
+ )} +
+ + {({ input: { value } }) => ( + <> + + + + )} + +
+ ); + } +} + +const enhanced = withFragmentContainer({ + email: graphql` + fragment EmailConfigContainer_email on EmailConfiguration { + enabled + ...FromContainer_email + ...SMTPContainer_email + } + `, +})(EmailConfigContainer); + +export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/Email/EmailConfigRoute.tsx b/src/core/client/admin/routes/Configure/sections/Email/EmailConfigRoute.tsx new file mode 100644 index 000000000..57c96298c --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Email/EmailConfigRoute.tsx @@ -0,0 +1,49 @@ +import { FormApi } from "final-form"; +import React from "react"; +import { graphql } from "react-relay"; + +import { EmailConfigRouteQueryResponse } from "coral-admin/__generated__/EmailConfigRouteQuery.graphql"; +import { withRouteConfig } from "coral-framework/lib/router"; +import { Delay, Spinner } from "coral-ui/components"; + +import EmailConfigContainer from "./EmailConfigContainer"; + +interface Props { + data: EmailConfigRouteQueryResponse | null; + form: FormApi; + submitting: boolean; +} + +class EmailConfigRoute extends React.Component { + public render() { + if (!this.props.data) { + return ( + + + + ); + } + return ( + + ); + } +} + +const enhanced = withRouteConfig({ + query: graphql` + query EmailConfigRouteQuery { + settings { + email { + ...EmailConfigContainer_email + } + } + } + `, + cacheConfig: { force: true }, +})(EmailConfigRoute); + +export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/Email/From.tsx b/src/core/client/admin/routes/Configure/sections/Email/From.tsx new file mode 100644 index 000000000..60f4793b2 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Email/From.tsx @@ -0,0 +1,79 @@ +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent } from "react"; +import { Field } from "react-final-form"; + +import { validateEmail } from "coral-framework/lib/validation"; +import { + FieldSet, + FormField, + HorizontalGutter, + InputDescription, + InputLabel, + TextField, + ValidationMessage, +} from "coral-ui/components"; + +interface Props { + disabled: boolean; +} + +const From: FunctionComponent = ({ disabled }) => ( + }> + + + From name + + + + Name as it will appear on all outgoing emails + + + + {({ input, meta }) => ( + <> + + {meta.touched && (meta.error || meta.submitError) && ( + + {meta.error || meta.submitError} + + )} + + )} + + + + + From email address + + + + Email address that will be used to send messages + + + + {({ input, meta }) => ( + <> + + {meta.touched && (meta.error || meta.submitError) && ( + + {meta.error || meta.submitError} + + )} + + )} + + + +); + +export default From; diff --git a/src/core/client/admin/routes/Configure/sections/Email/FromContainer.tsx b/src/core/client/admin/routes/Configure/sections/Email/FromContainer.tsx new file mode 100644 index 000000000..b39a51591 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Email/FromContainer.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { graphql } from "react-relay"; + +import { FromContainer_email } from "coral-admin/__generated__/FromContainer_email.graphql"; +import { withFragmentContainer } from "coral-framework/lib/relay"; + +import { OnInitValuesFct } from "./EmailConfigContainer"; +import From from "./From"; + +interface Props { + disabled: boolean; + onInitValues: OnInitValuesFct; + email: FromContainer_email; +} + +class FromContainer extends React.Component { + constructor(props: Props) { + super(props); + props.onInitValues(props.email); + } + + public render() { + const { disabled } = this.props; + return ; + } +} + +const enhanced = withFragmentContainer({ + email: graphql` + fragment FromContainer_email on EmailConfiguration { + enabled + fromName + fromEmail + } + `, +})(FromContainer); + +export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/Email/SMTP.tsx b/src/core/client/admin/routes/Configure/sections/Email/SMTP.tsx new file mode 100644 index 000000000..145729a00 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Email/SMTP.tsx @@ -0,0 +1,149 @@ +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent } from "react"; +import { Field } from "react-final-form"; + +import { + FieldSet, + FormField, + HorizontalGutter, + InputDescription, + InputLabel, + PasswordField, + TextField, + ValidationMessage, +} from "coral-ui/components"; + +import OnOffField from "../../OnOffField"; +import Subheader from "../../Subheader"; + +interface Props { + disabled: boolean; +} + +const SMTP: FunctionComponent = ({ disabled }) => ( + }> + + + SMTP host + + + (ex. smtp.sendgrid.com) + + + {({ input, meta }) => ( + <> + + {meta.touched && (meta.error || meta.submitError) && ( + + {meta.error || meta.submitError} + + )} + + )} + + + + + SMTP port + + + (ex. 25) + + + {({ input, meta }) => ( + <> + + {meta.touched && (meta.error || meta.submitError) && ( + + {meta.error || meta.submitError} + + )} + + )} + + + + + TLS + + + + + + SMTP Authentication + + + + + {({ input: { value: enabled } }) => ( + <> + + Email credentials + + + + Username + + + {({ input, meta }) => ( + <> + + {meta.touched && (meta.error || meta.submitError) && ( + + {meta.error || meta.submitError} + + )} + + )} + + + + + Password + + + {({ input, meta }) => ( + <> + + {meta.touched && (meta.error || meta.submitError) && ( + + {meta.error || meta.submitError} + + )} + + )} + + + + )} + + +); + +export default SMTP; diff --git a/src/core/client/admin/routes/Configure/sections/Email/SMTPContainer.tsx b/src/core/client/admin/routes/Configure/sections/Email/SMTPContainer.tsx new file mode 100644 index 000000000..034ef636d --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Email/SMTPContainer.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { graphql } from "react-relay"; + +import { SMTPContainer_email } from "coral-admin/__generated__/SMTPContainer_email.graphql"; +import { withFragmentContainer } from "coral-framework/lib/relay"; + +import { OnInitValuesFct } from "./EmailConfigContainer"; +import SMTP from "./SMTP"; + +interface Props { + email: SMTPContainer_email; + disabled: boolean; + onInitValues: OnInitValuesFct; +} + +class SMTPContainer extends React.Component { + constructor(props: Props) { + super(props); + props.onInitValues(props.email); + } + + public render() { + const { disabled } = this.props; + return ; + } +} + +const enhanced = withFragmentContainer({ + email: graphql` + fragment SMTPContainer_email on EmailConfiguration { + enabled + smtp { + host + port + secure + authentication + username + password + } + } + `, +})(SMTPContainer); + +export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/Email/index.ts b/src/core/client/admin/routes/Configure/sections/Email/index.ts new file mode 100644 index 000000000..8390b0e39 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Email/index.ts @@ -0,0 +1 @@ +export { default, default as EmailConfigRoute } from "./EmailConfigRoute"; diff --git a/src/core/client/admin/routes/Configure/sections/index.ts b/src/core/client/admin/routes/Configure/sections/index.ts index 9bddecdd0..5c3f6d776 100644 --- a/src/core/client/admin/routes/Configure/sections/index.ts +++ b/src/core/client/admin/routes/Configure/sections/index.ts @@ -1,5 +1,6 @@ export { AdvancedConfigRoute } from "./Advanced"; export { AuthConfigRoute } from "./Auth"; +export { EmailConfigRoute } from "./Email"; export { GeneralConfigRoute } from "./General"; export { ModerationConfigRoute } from "./Moderation"; export { OrganizationConfigRoute } from "./Organization"; diff --git a/src/core/client/admin/test/configure/__snapshots__/advanced.spec.tsx.snap b/src/core/client/admin/test/configure/__snapshots__/advanced.spec.tsx.snap index 9279a1813..8ede63042 100644 --- a/src/core/client/admin/test/configure/__snapshots__/advanced.spec.tsx.snap +++ b/src/core/client/admin/test/configure/__snapshots__/advanced.spec.tsx.snap @@ -70,6 +70,15 @@ exports[`renders configure advanced 1`] = ` Authentication +
  • + + Email + +
  • +
  • + + Email + +
  • +
  • + + Email + +
  • +
  • + + Email + +
  • +
  • + + Email + +
  • +
  • + + Email + +
  • = { -readonly [P in keyof T]: T[P] }; */ export type Promiseable = Promise | T; +export type Nullable = { [P in keyof T]: T[P] | null }; + +export type DeepNullable = T extends object + ? { + [P in keyof T]: T[P] extends Array + ? Array> + : T[P] extends ReadonlyArray + ? ReadonlyArray> + : DeepNullable + } + : T | null; + /** * Like Partial, but recurses down the object marking each field as Partial. */ diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index e7fba20fd..9e0f629ae 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -869,24 +869,27 @@ type DisableCommenting { } ################################################################################ -## Email +## EmailConfiguration ################################################################################ -type Email { +type SMTP { + secure: Boolean + host: String + port: Int + authentication: Boolean + username: String + password: String +} + +type EmailConfiguration { """ - enabled when True, will enable the emailing functionality in Coral. + enabled when true, will enable the emailing functionality in Coral. """ enabled: Boolean! - """ - smtpURI is the SMTP connection url to send emails on. - """ - smtpURI: String @auth(roles: [ADMIN]) - - """ - fromAddress is the email address that will be used to send emails from. - """ - fromAddress: String + fromName: String + fromEmail: String + smtp: SMTP! @auth(roles: [ADMIN]) } ################################################################################ @@ -1148,7 +1151,7 @@ type Settings { """ email is the set of credentials and settings associated with the organization. """ - email: Email! @auth(roles: [ADMIN, MODERATOR]) + email: EmailConfiguration! @auth(roles: [ADMIN, MODERATOR]) """ wordList will return a given list of words. @@ -2599,21 +2602,23 @@ input SettingsOIDCAuthIntegrationInput { issuer: String } -input SettingsEmailInput { +input SettingsSMTPInput { + secure: Boolean + host: String + port: Int + authentication: Boolean + username: String + password: String +} + +input SettingsEmailConfigurationInput { """ enabled when True, will enable the emailing functionality in Coral. """ enabled: Boolean - - """ - smtpURI is the SMTP connection url to send emails on. - """ - smtpURI: String - - """ - fromAddress is the email address that will be used to send emails from. - """ - fromAddress: String + smtp: SettingsSMTPInput + fromName: String + fromEmail: String } input SettingsWordListInput { @@ -3048,7 +3053,7 @@ input SettingsInput { """ email is the set of credentials and settings associated with the organization. """ - email: SettingsEmailInput + email: SettingsEmailConfigurationInput """ auth contains all the settings related to authentication and authorization. diff --git a/src/core/server/helpers/users.ts b/src/core/server/helpers/users.ts deleted file mode 100644 index 9892966a9..000000000 --- a/src/core/server/helpers/users.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { LocalProfile, User } from "coral-server/models/user"; - -/** - * getLocalProfile will get the LocalProfile from the User if it exists. - * - * @param user the User to pull the LocalProfile out of - */ -export function getLocalProfile( - user: Pick -): LocalProfile | undefined { - return user.profiles.find(({ type }) => type === "local") as - | LocalProfile - | undefined; -} - -/** - * hasLocalProfile will return true if the User has a LocalProfile, optionally - * checking the email on it as well. - * - * @param user the User to pull the LocalProfile out of - * @param withEmail when specified, will ensure that the LocalProfile has the - * specific email provided - */ -export function hasLocalProfile( - user: Pick, - withEmail?: string -): boolean { - const profile = getLocalProfile(user); - if (!profile) { - return false; - } - - if (withEmail && profile.id !== withEmail) { - return false; - } - - return true; -} diff --git a/src/core/server/models/settings.ts b/src/core/server/models/settings.ts index c9adc550b..649b749d3 100644 --- a/src/core/server/models/settings.ts +++ b/src/core/server/models/settings.ts @@ -1,6 +1,7 @@ import { Omit } from "coral-common/types"; import { GQLAuth, + GQLEmailConfiguration, GQLFacebookAuthIntegration, GQLGoogleAuthIntegration, GQLLiveConfiguration, @@ -13,6 +14,8 @@ import { export type LiveConfiguration = Omit; +export type EmailConfiguration = GQLEmailConfiguration; + export interface GlobalModerationSettings { live: LiveConfiguration; moderation: GQLMODERATION_MODE; @@ -77,7 +80,6 @@ export type Settings = GlobalModerationSettings & Pick< GQLSettings, | "charCount" - | "email" | "karma" | "wordList" | "integrations" @@ -93,6 +95,12 @@ export type Settings = GlobalModerationSettings & */ auth: Auth; + /** + * email is the set of credentials and settings associated with the + * organization. + */ + email: EmailConfiguration; + /** * closeCommenting contains settings related to the automatic closing of commenting on * Stories. diff --git a/src/core/server/models/tenant.ts b/src/core/server/models/tenant.ts index be33157cb..1637158b6 100644 --- a/src/core/server/models/tenant.ts +++ b/src/core/server/models/tenant.ts @@ -158,6 +158,7 @@ export async function createTenant( }, email: { enabled: false, + smtp: {}, }, karma: { enabled: true, diff --git a/src/core/server/queue/tasks/mailer/processor.ts b/src/core/server/queue/tasks/mailer/processor.ts index f1fbcdb4f..dd7ddfea2 100644 --- a/src/core/server/queue/tasks/mailer/processor.ts +++ b/src/core/server/queue/tasks/mailer/processor.ts @@ -6,9 +6,10 @@ import htmlToText from "html-to-text"; import Joi from "joi"; import { JSDOM } from "jsdom"; import { juiceResources } from "juice"; -import { camelCase } from "lodash"; +import { camelCase, isNil } from "lodash"; import { Db } from "mongodb"; import { createTransport } from "nodemailer"; +import { Options } from "nodemailer/lib/smtp-connection"; import now from "performance-now"; import { LanguageCode } from "coral-common/helpers/i18n/locales"; @@ -202,24 +203,28 @@ export const createJobProcessor = (options: MailProcessorOptions) => { return; } - const { enabled, smtpURI, fromAddress } = tenant.email; + const { enabled, smtp, fromEmail, fromName } = tenant.email; if (!enabled) { log.error("not sending email, it was disabled"); return; } - if (!smtpURI) { - log.error("email was enabled but the smtpURI configuration was missing"); + // Check that we have enough to generate the smtp credentials. + if (isNil(smtp.secure) || isNil(smtp.host) || isNil(smtp.port)) { + log.error("email enabled, but configuration is incomplete"); return; } - if (!fromAddress) { + if (!fromEmail) { log.error( "email was enabled but the fromAddress configuration was missing" ); return; } + // Construct the fromAddress. + const fromAddress = fromName ? `${fromName} <${fromEmail}>` : fromEmail; + const startTemplateGenerationTime = now(); // Get the message to send. @@ -242,8 +247,24 @@ export const createJobProcessor = (options: MailProcessorOptions) => { let transport = cache.get(tenantID); if (!transport) { try { + // Create the new transport options. + const opts: Options = { + host: smtp.host, + port: smtp.port, + secure: smtp.secure, + }; + if (smtp.authentication && smtp.username && smtp.password) { + // If authentication details are provided, add them to the transport + // configuration. + opts.auth = { + type: "login", + user: smtp.username, + pass: smtp.password, + }; + } + // Create the transport based on the smtp uri. - transport = createTransport(smtpURI); + transport = createTransport(opts); } catch (err) { throw new InternalError(err, "could not create email transport"); } diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index 45b7f43ab..bf016a0d6 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -74,6 +74,7 @@ configure-sideBarNavigation-authentication = Authentication configure-sideBarNavigation-moderation = Moderation configure-sideBarNavigation-organization = Organization configure-sideBarNavigation-advanced = Advanced +configure-sideBarNavigation-email = Email configure-sideBarNavigation-bannedAndSuspectWords = Banned and Suspect Words configure-sideBar-saveChanges = Save Changes @@ -151,6 +152,26 @@ configure-organization-emailExplanation = the organization should they have any questions about the status of their accounts or moderation questions. +### Email + +configure-email = Email settings +configure-email-configBoxEnabled = Enabled +configure-email-fromNameLabel = From name +configure-email-fromNameDescription = + Name as it will appear on all outgoing emails +configure-email-fromEmailLabel = From email address +configure-email-fromEmailDescription = + Email address that will be used to send messages +configure-email-smtpHostLabel = SMTP host +configure-email-smtpHostDescription = (ex. smtp.sendgrid.com) +configure-email-smtpPortLabel = SMTP port +configure-email-smtpPortDescription = (ex. 25) +configure-email-smtpTLSLabel = TLS +configure-email-smtpAuthenticationLabel = SMTP Authentication +configure-email-smtpCredentialsHeader = Email credentials +configure-email-smtpUsernameLabel = Username +configure-email-smtpPasswordLabel = Password + ### Authentication configure-auth-authIntegrations = Authentication Integrations