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 = ({
+
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 } }) => (
+ <>
+
+
+
+ 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