diff --git a/.vscode/settings.json b/.vscode/settings.json index ffc1f9486..de1aaf044 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,4 @@ { - "editor.formatOnSave": true, "prettier.eslintIntegration": true, "search.exclude": { "**/node_modules": true, diff --git a/packages/polymath-issuer/package.json b/packages/polymath-issuer/package.json index 155b79b94..4a21c528c 100644 --- a/packages/polymath-issuer/package.json +++ b/packages/polymath-issuer/package.json @@ -7,8 +7,8 @@ "node": ">=8.9" }, "scripts": { - "start:prod": "serve -s build", "start": "node scripts/start.js", + "start:prod": "serve -s build", "build": "node scripts/build.js", "test": "node ./scripts/test.js --env=jsdom", "typecheck": "flow --show-all-branches" diff --git a/packages/polymath-issuer/src/actions/compliance.js b/packages/polymath-issuer/src/actions/compliance.js index a6b494493..74a071f8e 100644 --- a/packages/polymath-issuer/src/actions/compliance.js +++ b/packages/polymath-issuer/src/actions/compliance.js @@ -14,6 +14,8 @@ import { formName as addInvestorFormName } from '../pages/compliance/components/ import { formName as editInvestorsFormName } from '../pages/compliance/components/EditInvestorsForm'; import { parseWhitelistCsv } from '../utils/parsers'; import { STAGE_OVERVIEW } from '../reducers/sto'; +import { PERM_TYPES } from '../constants'; +import Web3 from 'web3'; import type { Investor, Address } from '@polymathnetwork/js/types'; import type { GetState } from '../redux/reducer'; @@ -39,6 +41,31 @@ export const listLength = (listLength: number) => ({ listLength, }); +export const LOAD_MANAGERS = 'compliance/LOAD_MANAGERS'; +export const loadManagers = managers => ({ + type: LOAD_MANAGERS, + managers, +}); + +export const ADD_MANAGER = 'compliance/ADD_MANAGER'; +export const addManager = manager => ({ + type: ADD_MANAGER, + manager, +}); + +export const REMOVE_MANAGER = 'compliance/REMOVE_MANAGER'; +export const removeManager = address => ({ + type: REMOVE_MANAGER, + address, +}); + +export const TOGGLE_WHITELIST_MANAGEMENT = + 'compliance/TOGGLE_WHITELIST_MANAGEMENT'; +export const toggleWhitelistManagement = (isToggled: boolean) => ({ + type: TOGGLE_WHITELIST_MANAGEMENT, + isToggled, +}); + export const RESET_UPLOADED = 'compliance/RESET_UPLOADED'; export const resetUploaded = () => ({ type: RESET_UPLOADED }); @@ -59,6 +86,210 @@ export type InvestorCSVRow = [ string, ]; +// make more functional and switch transfermanager to module +async function getDelegateDetails(permissionManager, transferManager) { + const delegates = await permissionManager.getAllDelegates( + transferManager.address, + PERM_TYPES.ADMIN + ); + let delegateDetails = []; + for (const delegate of delegates) { + let details = await permissionManager.getDelegateDetails(delegate); + delegateDetails.push({ id: delegate, address: delegate, details }); + } + return delegateDetails; +} + +export const fetchManagers = () => async ( + dispatch: Function, + getState: GetState +) => { + dispatch(ui.fetching()); + // $FlowFixMe + try { + const st: SecurityToken = getState().token.token.contract; + const permissionManager = await st.getPermissionManager(); + if (!permissionManager) { + return; + } + const moduleMetadata = await st.getModule(permissionManager.address); + if (permissionManager && !moduleMetadata.isArchived) { + const transferManager = await st.getTransferManager(); + if (transferManager) { + const delegateDetails = await getDelegateDetails( + permissionManager, + transferManager + ); + dispatch(loadManagers(delegateDetails)); + } + dispatch(toggleWhitelistManagement(true)); + } else { + dispatch(toggleWhitelistManagement(false)); + } + dispatch(ui.fetched()); + } catch (e) { + console.log(e); + } +}; + +export const addAddressToTransferManager = ( + delegate: Address, + details: string +) => async (dispatch: Function, getState: GetState) => { + const st: SecurityToken = getState().token.token.contract; + const permissionManager = await st.getPermissionManager(); + const titles = ['Adding New Whitelist Manager', 'Setting Permissions']; + const isDelegate = await permissionManager.checkDelegate(delegate); + if (isDelegate) { + titles.shift(); + } + dispatch( + ui.tx( + titles, + async () => { + if (permissionManager) { + const transferManager = await st.getTransferManager(); + if (transferManager) { + if (!isDelegate) { + await permissionManager.addDelegate(delegate, details); + } + await permissionManager.changePermission( + delegate, + transferManager.address, + PERM_TYPES.ADMIN, + true + ); + } + } + }, + 'New Whistlist Manager Added', + () => { + dispatch(addManager({ address: delegate, details: details })); + }, + undefined, + undefined, + undefined, + true + ) + ); +}; + +// TODO: Add confirm dialog box +export const removeAddressFromTransferManager = (delegate: Address) => async ( + dispatch: Function, + getState: GetState +) => { + dispatch( + ui.confirm( +
+

+ Once removed, the whitelist manager will no longer have permission to + update the whitelist. Consult your legal team before removing a wallet + from the list. +

+
, + async () => { + dispatch( + ui.tx( + ['Removing Whitelist Manager'], + async () => { + const st: SecurityToken = getState().token.token.contract; + const permissionManager = await st.getPermissionManager(); + if (permissionManager) { + const transferManager = await st.getTransferManager(); + if (transferManager) { + await permissionManager.changePermission( + delegate, + transferManager.address, + PERM_TYPES.ADMIN, + false + ); + } + } + }, + 'Whitelist Manager Removed', + () => { + dispatch(removeManager(delegate)); + }, + undefined, + undefined, + undefined, + true + ) + ); + }, + `Remove the Whitelist Manager from the Whitelist Managers List?`, + undefined, + 'pui-large-confirm-modal' + ) + ); +}; + +export const archiveGeneralPermissionModule = () => async ( + dispatch: Function, + getState: GetState +) => { + const st: SecurityToken = getState().token.token.contract; + dispatch( + ui.tx( + ['Disabling General Permissions Manager'], + async () => { + const permissionManager = await st.getPermissionManager(); + await st.archiveModule(permissionManager.address); + }, + 'General Permissions Manager Disabled', + () => { + dispatch(toggleWhitelistManagement(false)); + dispatch(loadManagers([])); + }, + undefined, + undefined, + undefined, + true + ) + ); +}; + +export const addGeneralPermissionModule = () => async ( + dispatch: Function, + getState: GetState +) => { + const st: SecurityToken = getState().token.token.contract; + const permissionManager = await st.getPermissionManager(); + const transferManager = await st.getTransferManager(); + let moduleMetadata = {}; + let delegateDetails = []; + + if (permissionManager) + moduleMetadata = await st.getModule(permissionManager.address); + + dispatch( + ui.tx( + ['Enabling General Permissions Manager for General Transfer Manager'], + async () => { + if (moduleMetadata.isArchived) { + await st.unarchiveModule(permissionManager.address); + delegateDetails = await getDelegateDetails( + permissionManager, + transferManager + ); + } else { + await st.setPermissionManager(); + } + }, + 'General Permissions Manager for General Transfer Manager Enabled', + () => { + dispatch(loadManagers(delegateDetails)); + dispatch(toggleWhitelistManagement(true)); + }, + undefined, + undefined, + undefined, + true + ) + ); +}; + export const fetchWhitelist = () => async ( dispatch: Function, getState: GetState diff --git a/packages/polymath-issuer/src/constants.js b/packages/polymath-issuer/src/constants.js index 95f63540c..c31153c6f 100644 --- a/packages/polymath-issuer/src/constants.js +++ b/packages/polymath-issuer/src/constants.js @@ -8,6 +8,10 @@ export const EVENT_TYPES = { TOKEN_PURCHASE: 'TokenPurchase', }; +export const PERM_TYPES = { + ADMIN: 'ADMIN', +}; + export const MODULE_TYPES = { PERMISSION: 1, TRANSFER: 2, diff --git a/packages/polymath-issuer/src/pages/compliance/CompliancePage.js b/packages/polymath-issuer/src/pages/compliance/CompliancePage.js index b62b77523..3b0ebf53c 100644 --- a/packages/polymath-issuer/src/pages/compliance/CompliancePage.js +++ b/packages/polymath-issuer/src/pages/compliance/CompliancePage.js @@ -1,64 +1,69 @@ // @flow /* eslint-disable react/jsx-no-bind, react/no-unused-state */ // TODO @bshevchenko -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { reset } from 'redux-form'; -import { BigNumber } from 'bignumber.js'; import { - Page, - etherscanAddress, addressShortifier, confirm, + etherscanAddress, NotFoundPage, + Page, + Grid, } from '@polymathnetwork/ui'; +import { BigNumber } from 'bignumber.js'; import { Button, DataTable, - // PaginationV2, - Modal, // DatePicker, // DatePickerInput, Icon, InlineNotification, - Toggle, - TextInput, + // PaginationV2, + Modal, OverflowMenu, OverflowMenuItem, + TextInput, + Toggle, } from 'carbon-components-react'; -import type { - Investor, - Address, - SecurityToken, -} from '@polymathnetwork/js/types'; - -import Progress from '../token/components/Progress'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { reset } from 'redux-form'; import { - importWhitelist, - exportWhitelist, addInvestor, + disableOwnershipRestrictions, + editInvestors, + enableOwnershipRestrictions, + exportWhitelist, fetchWhitelist, + importWhitelist, listLength, + PERMANENT_LOCKUP_TS, removeInvestors, - editInvestors, resetUploaded, - disableOwnershipRestrictions, - enableOwnershipRestrictions, - updateOwnershipPercentage, - PERMANENT_LOCKUP_TS, toggleFreeze, + updateOwnershipPercentage, + fetchManagers, + toggleWhitelistManagement, + addGeneralPermissionModule, + archiveGeneralPermissionModule, } from '../../actions/compliance'; +import Progress from '../token/components/Progress'; import AddInvestorForm, { formName as addInvestorFormName, } from './components/AddInvestorForm'; import { formName as editInvestorsFormName } from './components/EditInvestorsForm'; import ImportWhitelistModal from './components/ImportWhitelistModal'; +import WhitelistTable from './components/WhitelistTable'; +import WhitelistModal from './components/WhitelistModal'; +import './style.scss'; +import type { + Investor, + Address, + SecurityToken, +} from '@polymathnetwork/js/types'; import type { RootState } from '../../redux/reducer'; import type { InvestorCSVRow } from '../../actions/compliance'; -import './style.scss'; - const { Table, TableBody, @@ -101,6 +106,8 @@ type DispatchProps = {| enableOwnershipRestrictions: (percentage?: number) => any, updateOwnershipPercentage: (percentage: number) => any, toggleFreeze: () => any, + addGeneralPermissionModule: () => any, + archiveGeneralPermissionModule: () => any, |}; const mapStateToProps = (state: RootState) => ({ @@ -112,6 +119,7 @@ const mapStateToProps = (state: RootState) => ({ isPercentagePaused: state.whitelist.percentageTM.isPaused, percentage: state.whitelist.percentageTM.percentage, isTokenFrozen: state.whitelist.freezeStatus, + isWhitelistToggled: state.whitelist.isToggled, }); const mapDispatchToProps = { @@ -129,6 +137,10 @@ const mapDispatchToProps = { enableOwnershipRestrictions, updateOwnershipPercentage, toggleFreeze, + fetchManagers, + toggleWhitelistManagement, + addGeneralPermissionModule, + archiveGeneralPermissionModule, }; type Props = StateProps & DispatchProps; @@ -142,6 +154,8 @@ type State = {| startDateAdded: ?Date, endDateAdded: ?Date, isPercentageToggled: boolean, + isWhitelistToggled: boolean, + isWhitelistModalOpen: boolean, percentage: ?number, |}; @@ -177,6 +191,7 @@ class CompliancePage extends Component { if (this.props.percentage) { this.setState({ percentage: this.props.percentage }); } + this.props.fetchManagers(); } componentWillReceiveProps(nextProps) { @@ -404,6 +419,14 @@ class CompliancePage extends Component { this.props.removeInvestors(addresses); }; + handleToggleWhitelist = async (isToggled: boolean) => { + if (isToggled) { + await this.props.addGeneralPermissionModule(); + } else { + await this.props.archiveGeneralPermissionModule(); + } + }; + handleTogglePercentage = (isToggled: boolean) => { const { isPercentageEnabled, isPercentagePaused } = this.props; if (!isPercentageEnabled) { @@ -617,166 +640,173 @@ class CompliancePage extends Component { // const paginatedRows = this.paginationRendering() return ( -
- -

Token Whitelist

-

- Whitelisted addresses may hold, buy, or sell the security token and - may participate into the STO.
Security token buy/sell - operations may be subject to restrictions. -

-
- -
- - + + + +

Token Whitelist

+

+ Whitelisted addresses may hold, buy, or sell the security token + and may participate into the STO.
Security token buy/sell + operations may be subject to restrictions. +

+
+ + + + + -
-
- {/* - - {}} - onChange={() => {}} - /> - {}} - onChange={() => {}} - /> - - */} - -
- - */} + + + + + + -
+ + + + + +
+
+
+ {/* + + */} +

Ownership Restrictions

+
+
+ + +
-
- -
- - + > + +
+ + +
+
+
+
-
- - - - - -
-
+ + +
+
+
+

Whitelist Management

+
+
+ + +
- {/* - - - -

- Please enter the information below to edit the chosen investors. -

-
- -
- */} -
+
+ +
+
+
+
+
+
+ + ); } diff --git a/packages/polymath-issuer/src/pages/compliance/components/WhitelistModal.js b/packages/polymath-issuer/src/pages/compliance/components/WhitelistModal.js new file mode 100644 index 000000000..9553c2f1f --- /dev/null +++ b/packages/polymath-issuer/src/pages/compliance/components/WhitelistModal.js @@ -0,0 +1,103 @@ +// @flow + +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { Modal, Button } from '@polymathnetwork/ui'; +import { withFormik } from 'formik'; +import { Form } from 'carbon-components-react'; +import { + bull, + PageCentered, + ContentBox, + Heading, + FormItem, + TextInput, +} from '@polymathnetwork/ui'; +import validator from '@polymathnetwork/ui/validator'; +import { addAddressToTransferManager } from '../../../actions/compliance'; + +type Props = { + isOpen: boolean, + handleClose: () => any, +}; + +const formSchema = validator.object().shape({ + address: validator + .string() + .isAddress('Invalid Address') + .isRequired('Required'), + details: validator.string().isRequired('Required'), +}); + +export const ConfirmEmailFormComponent = ({ handleSubmit, handleClose }) => ( +
+ + + Whitelist Manager Wallet Address + + + + + + + + Whitelist Manager Details + + + + + + + + + +
+); + +const formikEnhancer = withFormik({ + validationSchema: formSchema, + displayName: 'ConfirmEmailForm', + validateOnChange: false, + handleSubmit: (values, { setFieldError, props }) => { + const { dispatch, approvedManagers } = props; + const addressExists = approvedManagers.find( + i => i.address === values.address + ); + if (addressExists) { + setFieldError('address', 'Address is already added to Whitelist Manager'); + return; + } + props.handleClose(); + dispatch(addAddressToTransferManager(values.address, values.details)); + }, +}); + +const mapStateToProps = state => ({ + approvedManagers: state.whitelist.approvedManagers, +}); + +const FormikEnhancedForm = formikEnhancer(ConfirmEmailFormComponent); +const ConnectedForm = connect(mapStateToProps)(FormikEnhancedForm); + +class WhitelistModal extends Component { + render() { + const { isOpen, handleClose } = this.props; + return ( + + Add Whitelist Manager + +

+ Specify the whitelist manager address of the new whitelist manager. + Each manager will have permission to update the whitelist. Consult + with your legal team before adding a new wallet to the list. +

+ +
+
+ ); + } +} + +export default WhitelistModal; diff --git a/packages/polymath-issuer/src/pages/compliance/components/WhitelistTable.js b/packages/polymath-issuer/src/pages/compliance/components/WhitelistTable.js new file mode 100644 index 000000000..1df4adf1e --- /dev/null +++ b/packages/polymath-issuer/src/pages/compliance/components/WhitelistTable.js @@ -0,0 +1,140 @@ +// @flow + +import React, { Component } from 'react'; +import { DataTable, Icon } from 'carbon-components-react'; +import { Button } from '@polymathnetwork/ui'; +import WhitelistModal from './WhitelistModal'; +import { connect } from 'react-redux'; +import { removeAddressFromTransferManager } from '../../../actions/compliance'; +const { + TableContainer, + Table, + TableHead, + TableRow, + TableBody, + TableCell, + TableHeader, + TableToolbar, + TableToolbarContent, +} = DataTable; + +const columns = [ + { + header: 'Whitelist Manager Wallet Address', + key: 'address', + width: 250, + Cell: ({ value }) => value, + }, + { + header: 'Manager Details', + key: 'details', + width: 250, + Cell: ({ value }) => value, + }, +]; + +type State = {| + isWhitelistModalOpen: boolean, +|}; + +class WhitelistTable extends Component { + state = { + isWhitelistModalOpen: false, + }; + + handleOpen = () => { + this.setState({ isWhitelistModalOpen: true }); + }; + + handleClose = () => { + this.setState({ isWhitelistModalOpen: false }); + }; + + handleDelete = id => { + this.props.removeAddressFromTransferManager(id); + }; + + render() { + const { approvedManagers } = this.props; + return ( +
+ + { + return ( + + + + {/* pass in `onInputChange` change here to make filtering work */} + + + + + + + {headers.map(header => ( + + {header.header} + + ))} + + + + + {rows.map(row => ( + console.log('test')} + > + {row.cells.map(cell => ( + {cell.value} + ))} + {approvedManagers.length > 0 ? ( + this.handleDelete(row.id)} + > + + + ) : ( + + )} + + ))} + +
+
+ ); + }} + /> +
+ ); + } +} + +const mapStateToProps = state => ({ + approvedManagers: state.whitelist.approvedManagers, +}); + +const mapDispatchToProps = { + removeAddressFromTransferManager, +}; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(WhitelistTable); diff --git a/packages/polymath-issuer/src/pages/compliance/style.scss b/packages/polymath-issuer/src/pages/compliance/style.scss index 3125165c8..5a6c919b3 100644 --- a/packages/polymath-issuer/src/pages/compliance/style.scss +++ b/packages/polymath-issuer/src/pages/compliance/style.scss @@ -1,20 +1,73 @@ +.delete-icon { + cursor: pointer; +} + +.form-item-header { + margin-top: 12px; + margin-bottom: 5px; + font-size: 14px; +} + .compliance-form { - width: 60%; + width: 100%; + + .whitelist-settings { + display: flex; + flex-direction: column; + margin-top: 20px; + margin-bottom: 20px; + > .bx--form-item { + //&:first-child { + // max-width: 325px; + //} + + &:last-child { + margin-bottom: 0 !important; + overflow: scroll; + + .bx--form-item { + position: relative; + float: left; + + input { + width: 84px; + min-width: 84px; + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + } + + &:after { + content: '%'; + position: absolute; + top: 13px; + left: 55px; + color: #8c9ba5; + font-size: 14px; + } + } + } + } + } .compliance-settings { display: flex; + flex-direction: row; margin-top: 20px; margin-bottom: 20px; > .bx--form-item { - max-width: 220px; + max-width: 300px; //&:first-child { // max-width: 325px; //} &:last-child { - margin-top: -22px; + margin-top: -24px; margin-bottom: 0 !important; height: 100px; diff --git a/packages/polymath-issuer/src/reducers/compliance.js b/packages/polymath-issuer/src/reducers/compliance.js index 2eb55749f..57a28da73 100644 --- a/packages/polymath-issuer/src/reducers/compliance.js +++ b/packages/polymath-issuer/src/reducers/compliance.js @@ -25,6 +25,7 @@ export type WhitelistState = {| listLength: number, freezeStatus: ?boolean, isFrozenModalOpen: ?boolean, + approvedManagers: Array, |}; const defaultState: WhitelistState = { @@ -42,6 +43,8 @@ const defaultState: WhitelistState = { listLength: 10, freezeStatus: null, isFrozenModalOpen: null, + approvedManagers: [], + isToggled: false, }; // NOTE @RafaelVidaurre: WARNING For some reason this reducer is being renamed. @@ -49,6 +52,35 @@ const defaultState: WhitelistState = { // eslint-disable-next-line complexity export default (state: WhitelistState = defaultState, action: Object) => { switch (action.type) { + case a.TOGGLE_WHITELIST_MANAGEMENT: + return { + ...state, + isToggled: action.isToggled, + }; + case a.REMOVE_MANAGER: + let index = state.approvedManagers.findIndex( + i => i.address === action.address + ); + return { + ...state, + approvedManagers: [ + ...state.approvedManagers.slice(0, index), + ...state.approvedManagers.slice(index + 1), + ], + }; + case a.ADD_MANAGER: + return { + ...state, + approvedManagers: [ + ...state.approvedManagers, + { ...action.manager, id: action.manager.address }, + ], + }; + case a.LOAD_MANAGERS: + return { + ...state, + approvedManagers: action.managers, + }; case a.TRANSFER_MANAGER: return { ...state, diff --git a/packages/polymath-js/src/contracts/PermissionManager.js b/packages/polymath-js/src/contracts/PermissionManager.js index a79e37567..6d685ac63 100644 --- a/packages/polymath-js/src/contracts/PermissionManager.js +++ b/packages/polymath-js/src/contracts/PermissionManager.js @@ -18,4 +18,39 @@ export default class PermissionManager extends Contract { } version = version; } + + async addDelegate(at: Address, details: string) { + return this._tx(this._methods.addDelegate(at, this._toBytes(details))); + } + + async checkDelegate(at: Address) { + return this._methods.checkDelegate(at).call(); + } + + async getDelegateDetails(delegate: Address) { + let details = await this._methods.delegateDetails(delegate).call(); + return this._toAscii(details); + } + + async getAllDelegates(moduleAddress: Address, permission: string) { + return this._methods + .getAllDelegatesWithPerm(moduleAddress, this._toBytes(permission)) + .call(); + } + + async changePermission( + delegate: Address, + moduleAddress: Address, + permission: string, + valid: boolean + ) { + return this._tx( + this._methods.changePermission( + delegate, + moduleAddress, + this._toBytes(permission), + valid + ) + ); + } } diff --git a/packages/polymath-js/src/contracts/SecurityToken.js b/packages/polymath-js/src/contracts/SecurityToken.js index afb47cb24..9a3e98af6 100644 --- a/packages/polymath-js/src/contracts/SecurityToken.js +++ b/packages/polymath-js/src/contracts/SecurityToken.js @@ -474,6 +474,21 @@ export default class SecurityToken extends Contract { ); } + async setPermissionManager(): Promise { + const generalPermissionManagerFactory = await this.getModuleFactory( + 'GeneralPermissionManager', + MODULE_TYPES.PERMISSION + ); + const setupCost = await generalPermissionManagerFactory.setupCost(); + const data = this._toBytes(''); + return this.addModule( + generalPermissionManagerFactory.address, + data, + PolyToken.addDecimals(setupCost), + 0 + ); + } + async setCountTM(count: number): Promise { const countTransferManagerFactory = await this.getModuleFactory( 'CountTransferManager', @@ -500,4 +515,16 @@ export default class SecurityToken extends Contract { 0 ); } + + async getModule(at: Address): Promise { + return this._methods.getModule(at).call(); + } + + async arhiveModule(at: Address): Promise { + return this._tx(this._methods.archiveModule(at)); + } + + async unarchiveModule(at: Address): Promise { + return this._tx(this._methods.unarchiveModule(at)); + } } diff --git a/packages/polymath-offchain/src/utils/emails.js b/packages/polymath-offchain/src/utils/emails.js index 8e8e4b0be..1db5eeb7b 100644 --- a/packages/polymath-offchain/src/utils/emails.js +++ b/packages/polymath-offchain/src/utils/emails.js @@ -42,9 +42,6 @@ export const sendEmail = async ( from: { email: 'noreply@polymath.network', name: 'Polymath Network' }, replyTo, to: { email, name }, - // @FIXME remon-nashid: requests to SendGrid fail when cc and receiver addresses are the same. - // hardcoding CC email to my email to save the day. - cc: 'remon@polymath.network', subject, html: body, };