feat: initial implementation (#2409)

This commit is contained in:
Wyatt Johnson
2019-07-23 17:20:40 +00:00
committed by GitHub
parent fc464bd7c9
commit b1732f8a00
24 changed files with 646 additions and 70 deletions
+2
View File
@@ -10,6 +10,7 @@ import ConfigureRoute from "./routes/Configure";
import {
AdvancedConfigRoute,
AuthConfigRoute,
EmailConfigRoute,
GeneralConfigRoute,
ModerationConfigRoute,
OrganizationConfigRoute,
@@ -71,6 +72,7 @@ export default makeRouteConfig(
<Route path="wordList" {...WordListConfigRoute.routeConfig} />
<Route path="auth" {...AuthConfigRoute.routeConfig} />
<Route path="advanced" {...AdvancedConfigRoute.routeConfig} />
<Route path="email" {...EmailConfigRoute.routeConfig} />
</Route>
</Route>
</Route>
@@ -49,6 +49,9 @@ const Configure: FunctionComponent<Props> = ({
<Localized id="configure-sideBarNavigation-authentication">
<Link to="/admin/configure/auth">Authentication</Link>
</Localized>
<Localized id="configure-sideBarNavigation-email">
<Link to="/admin/configure/email">Email</Link>
</Localized>
<Localized id="configure-sideBarNavigation-advanced">
<Link to="/admin/configure/advanced">Advanced</Link>
</Localized>
@@ -0,0 +1,3 @@
.title {
display: flex;
}
@@ -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<GQLEmailConfiguration>;
export type OnInitValuesFct = (values: DeepPartial<FormProps>) => void;
class EmailConfigContainer extends React.Component<Props> {
public static routeConfig: RouteProps;
private initialValues: DeepPartial<FormProps> = {};
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<DeepPartial<FormProps>>(
this.initialValues,
values
);
};
public render() {
const { email, submitting } = this.props;
return (
<HorizontalGutter size="double">
<Field name="email.enabled" type="checkbox" parse={parseBool}>
{({ input }) => (
<Header
className={styles.title}
container={<Flex justifyContent="space-between" />}
>
<div>
<Localized id="configure-email">
<span>Email settings</span>
</Localized>
</div>
<div>
<FormField>
<Localized id="configure-email-configBoxEnabled">
<CheckBox
id={input.name}
name={input.name}
onChange={input.onChange}
checked={input.value}
disabled={submitting}
>
Enabled
</CheckBox>
</Localized>
</FormField>
</div>
</Header>
)}
</Field>
<Field name="email.enabled" subscription={{ value: true }}>
{({ input: { value } }) => (
<>
<FromContainer
email={email}
disabled={submitting || !value}
onInitValues={this.handleOnInitValues}
/>
<SMTPContainer
email={email}
disabled={submitting || !value}
onInitValues={this.handleOnInitValues}
/>
</>
)}
</Field>
</HorizontalGutter>
);
}
}
const enhanced = withFragmentContainer<Props>({
email: graphql`
fragment EmailConfigContainer_email on EmailConfiguration {
enabled
...FromContainer_email
...SMTPContainer_email
}
`,
})(EmailConfigContainer);
export default enhanced;
@@ -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<Props> {
public render() {
if (!this.props.data) {
return (
<Delay>
<Spinner />
</Delay>
);
}
return (
<EmailConfigContainer
email={this.props.data.settings.email}
form={this.props.form}
submitting={this.props.submitting}
/>
);
}
}
const enhanced = withRouteConfig<Props>({
query: graphql`
query EmailConfigRouteQuery {
settings {
email {
...EmailConfigContainer_email
}
}
}
`,
cacheConfig: { force: true },
})(EmailConfigRoute);
export default enhanced;
@@ -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<Props> = ({ disabled }) => (
<HorizontalGutter size="oneAndAHalf" container={<FieldSet />}>
<FormField>
<Localized id="configure-email-fromNameLabel">
<InputLabel>From name</InputLabel>
</Localized>
<Localized id="configure-email-fromNameDescription">
<InputDescription>
Name as it will appear on all outgoing emails
</InputDescription>
</Localized>
<Field name="email.fromName">
{({ input, meta }) => (
<>
<TextField fullWidth disabled={disabled} {...input} />
{meta.touched && (meta.error || meta.submitError) && (
<ValidationMessage fullWidth>
{meta.error || meta.submitError}
</ValidationMessage>
)}
</>
)}
</Field>
</FormField>
<FormField>
<Localized id="configure-email-fromEmailLabel">
<InputLabel>From email address</InputLabel>
</Localized>
<Localized id="configure-email-fromEmailDescription">
<InputDescription>
Email address that will be used to send messages
</InputDescription>
</Localized>
<Field name="email.fromEmail" validate={validateEmail}>
{({ input, meta }) => (
<>
<TextField
type="email"
fullWidth
color={
meta.touched && (meta.error || meta.submitError)
? "error"
: "regular"
}
disabled={disabled}
{...input}
/>
{meta.touched && (meta.error || meta.submitError) && (
<ValidationMessage fullWidth>
{meta.error || meta.submitError}
</ValidationMessage>
)}
</>
)}
</Field>
</FormField>
</HorizontalGutter>
);
export default From;
@@ -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<Props> {
constructor(props: Props) {
super(props);
props.onInitValues(props.email);
}
public render() {
const { disabled } = this.props;
return <From disabled={disabled} />;
}
}
const enhanced = withFragmentContainer<Props>({
email: graphql`
fragment FromContainer_email on EmailConfiguration {
enabled
fromName
fromEmail
}
`,
})(FromContainer);
export default enhanced;
@@ -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<Props> = ({ disabled }) => (
<HorizontalGutter size="oneAndAHalf" container={<FieldSet />}>
<FormField>
<Localized id="configure-email-smtpHostLabel">
<InputLabel>SMTP host</InputLabel>
</Localized>
<Localized id="configure-email-smtpHostDescription">
<InputDescription>(ex. smtp.sendgrid.com)</InputDescription>
</Localized>
<Field name="email.smtp.host">
{({ input, meta }) => (
<>
<TextField
id={input.name}
fullWidth
disabled={disabled}
{...input}
/>
{meta.touched && (meta.error || meta.submitError) && (
<ValidationMessage fullWidth>
{meta.error || meta.submitError}
</ValidationMessage>
)}
</>
)}
</Field>
</FormField>
<FormField>
<Localized id="configure-email-smtpPortLabel">
<InputLabel>SMTP port</InputLabel>
</Localized>
<Localized id="configure-email-smtpPortDescription">
<InputDescription>(ex. 25)</InputDescription>
</Localized>
<Field name="email.smtp.port">
{({ input, meta }) => (
<>
<TextField
id={input.name}
type="number"
fullWidth
disabled={disabled}
{...input}
/>
{meta.touched && (meta.error || meta.submitError) && (
<ValidationMessage fullWidth>
{meta.error || meta.submitError}
</ValidationMessage>
)}
</>
)}
</Field>
</FormField>
<FormField>
<Localized id="configure-email-smtpTLSLabel">
<InputLabel>TLS</InputLabel>
</Localized>
<OnOffField name="email.smtp.secure" disabled={disabled} />
</FormField>
<FormField>
<Localized id="configure-email-smtpAuthenticationLabel">
<InputLabel>SMTP Authentication</InputLabel>
</Localized>
<OnOffField name="email.smtp.authentication" disabled={disabled} />
</FormField>
<Field name="email.smtp.authentication" subscription={{ value: true }}>
{({ input: { value: enabled } }) => (
<>
<Localized id="configure-email-smtpCredentialsHeader">
<Subheader>Email credentials</Subheader>
</Localized>
<FormField>
<Localized id="configure-email-smtpUsernameLabel">
<InputLabel>Username</InputLabel>
</Localized>
<Field name="email.smtp.username">
{({ input, meta }) => (
<>
<TextField
id={input.name}
fullWidth
disabled={disabled || !enabled}
{...input}
/>
{meta.touched && (meta.error || meta.submitError) && (
<ValidationMessage fullWidth>
{meta.error || meta.submitError}
</ValidationMessage>
)}
</>
)}
</Field>
</FormField>
<FormField>
<Localized id="configure-email-smtpPasswordLabel">
<InputLabel>Password</InputLabel>
</Localized>
<Field name="email.smtp.password">
{({ input, meta }) => (
<>
<PasswordField
id={input.name}
color={
meta.touched && (meta.error || meta.submitError)
? "error"
: "regular"
}
{...input}
disabled={disabled || !enabled}
fullWidth
/>
{meta.touched && (meta.error || meta.submitError) && (
<ValidationMessage fullWidth>
{meta.error || meta.submitError}
</ValidationMessage>
)}
</>
)}
</Field>
</FormField>
</>
)}
</Field>
</HorizontalGutter>
);
export default SMTP;
@@ -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<Props> {
constructor(props: Props) {
super(props);
props.onInitValues(props.email);
}
public render() {
const { disabled } = this.props;
return <SMTP disabled={disabled} />;
}
}
const enhanced = withFragmentContainer<Props>({
email: graphql`
fragment SMTPContainer_email on EmailConfiguration {
enabled
smtp {
host
port
secure
authentication
username
password
}
}
`,
})(SMTPContainer);
export default enhanced;
@@ -0,0 +1 @@
export { default, default as EmailConfigRoute } from "./EmailConfigRoute";
@@ -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";
@@ -70,6 +70,15 @@ exports[`renders configure advanced 1`] = `
Authentication
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/email"
onClick={[Function]}
>
Email
</a>
</li>
<li>
<a
className="Link-link Link-linkActive"
@@ -1936,6 +1936,15 @@ exports[`renders configure auth 1`] = `
Authentication
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/email"
onClick={[Function]}
>
Email
</a>
</li>
<li>
<a
className="Link-link"
@@ -70,6 +70,15 @@ exports[`renders configure general 1`] = `
Authentication
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/email"
onClick={[Function]}
>
Email
</a>
</li>
<li>
<a
className="Link-link"
@@ -70,6 +70,15 @@ exports[`renders configure moderation 1`] = `
Authentication
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/email"
onClick={[Function]}
>
Email
</a>
</li>
<li>
<a
className="Link-link"
@@ -70,6 +70,15 @@ exports[`renders configure organization 1`] = `
Authentication
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/email"
onClick={[Function]}
>
Email
</a>
</li>
<li>
<a
className="Link-link"
@@ -70,6 +70,15 @@ exports[`renders configure wordList 1`] = `
Authentication
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/email"
onClick={[Function]}
>
Email
</a>
</li>
<li>
<a
className="Link-link"
+12
View File
@@ -20,6 +20,18 @@ export type Writeable<T> = { -readonly [P in keyof T]: T[P] };
*/
export type Promiseable<T> = Promise<T> | T;
export type Nullable<T> = { [P in keyof T]: T[P] | null };
export type DeepNullable<T> = T extends object
? {
[P in keyof T]: T[P] extends Array<infer U>
? Array<DeepNullable<U>>
: T[P] extends ReadonlyArray<infer V>
? ReadonlyArray<DeepNullable<V>>
: DeepNullable<T[P]>
}
: T | null;
/**
* Like Partial, but recurses down the object marking each field as Partial.
*/
@@ -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.
-38
View File
@@ -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<User, "profiles">
): 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<User, "profiles">,
withEmail?: string
): boolean {
const profile = getLocalProfile(user);
if (!profile) {
return false;
}
if (withEmail && profile.id !== withEmail) {
return false;
}
return true;
}
+9 -1
View File
@@ -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<GQLLiveConfiguration, "configurable">;
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.
+1
View File
@@ -158,6 +158,7 @@ export async function createTenant(
},
email: {
enabled: false,
smtp: {},
},
karma: {
enabled: true,
@@ -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");
}
+21
View File
@@ -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