Cleanup onboarding, enforce metadata.expiration as timestamp

This commit is contained in:
Ben Gotow 2017-09-10 22:45:48 -07:00
parent 44b00cbbf5
commit bfaae56b59
32 changed files with 331 additions and 357 deletions

View file

@ -3,7 +3,7 @@
"main": "./lib/main", "main": "./lib/main",
"version": "0.1.0", "version": "0.1.0",
"serverUrl": { "serverUrl": {
"development": "http://localhost:5100", "development": "http://localhost:5101",
"staging": "https://link-staging.getmailspring.com", "staging": "https://link-staging.getmailspring.com",
"production": "https://link.getmailspring.com" "production": "https://link.getmailspring.com"
}, },

View file

@ -1,34 +1,34 @@
const AccountTypes = [ const AccountProviders = [
{ {
type: 'gmail', provider: 'gmail',
displayName: 'Gmail or G Suite', displayName: 'Gmail or G Suite',
icon: 'ic-settings-account-gmail.png', icon: 'ic-settings-account-gmail.png',
headerIcon: 'setup-icon-provider-gmail.png', headerIcon: 'setup-icon-provider-gmail.png',
color: '#e99999', color: '#e99999',
}, },
{ {
type: 'office365', provider: 'office365',
displayName: 'Office 365', displayName: 'Office 365',
icon: 'ic-settings-account-outlook.png', icon: 'ic-settings-account-outlook.png',
headerIcon: 'setup-icon-provider-outlook.png', headerIcon: 'setup-icon-provider-outlook.png',
color: '#0078d7', color: '#0078d7',
}, },
{ {
type: 'yahoo', provider: 'yahoo',
displayName: 'Yahoo', displayName: 'Yahoo',
icon: 'ic-settings-account-yahoo.png', icon: 'ic-settings-account-yahoo.png',
headerIcon: 'setup-icon-provider-yahoo.png', headerIcon: 'setup-icon-provider-yahoo.png',
color: '#a76ead', color: '#a76ead',
}, },
{ {
type: 'icloud', provider: 'icloud',
displayName: 'iCloud', displayName: 'iCloud',
icon: 'ic-settings-account-icloud.png', icon: 'ic-settings-account-icloud.png',
headerIcon: 'setup-icon-provider-icloud.png', headerIcon: 'setup-icon-provider-icloud.png',
color: '#61bfe9', color: '#61bfe9',
}, },
{ {
type: 'fastmail', provider: 'fastmail',
displayName: 'FastMail', displayName: 'FastMail',
title: 'Set up your account', title: 'Set up your account',
icon: 'ic-settings-account-fastmail.png', icon: 'ic-settings-account-fastmail.png',
@ -36,7 +36,7 @@ const AccountTypes = [
color: '#24345a', color: '#24345a',
}, },
{ {
type: 'imap', provider: 'imap',
displayName: 'IMAP / SMTP', displayName: 'IMAP / SMTP',
title: 'Set up your IMAP account', title: 'Set up your IMAP account',
icon: 'ic-settings-account-imap.png', icon: 'ic-settings-account-imap.png',
@ -45,4 +45,4 @@ const AccountTypes = [
}, },
] ]
export default AccountTypes; export default AccountProviders;

View file

@ -5,26 +5,26 @@ import {RetinaImg} from 'nylas-component-kit';
import {NylasAPIRequest, Actions} from 'nylas-exports'; import {NylasAPIRequest, Actions} from 'nylas-exports';
import OnboardingActions from '../onboarding-actions'; import OnboardingActions from '../onboarding-actions';
import {buildAndValidateAccount} from '../onboarding-helpers'; import {finalizeAndValidateAccount} from '../onboarding-helpers';
import FormErrorMessage from '../form-error-message'; import FormErrorMessage from '../form-error-message';
import AccountTypes from '../account-types' import AccountProviders from '../account-providers'
const CreatePageForForm = (FormComponent) => { const CreatePageForForm = (FormComponent) => {
return class Composed extends React.Component { return class Composed extends React.Component {
static displayName = FormComponent.displayName; static displayName = FormComponent.displayName;
static propTypes = { static propTypes = {
accountInfo: React.PropTypes.object, account: React.PropTypes.object,
}; };
constructor(props) { constructor(props) {
super(props); super(props);
this.state = Object.assign({ this.state = Object.assign({
accountInfo: JSON.parse(JSON.stringify(this.props.accountInfo)), account: this.props.account.clone(),
errorFieldNames: [], errorFieldNames: [],
errorMessage: null, errorMessage: null,
}, FormComponent.validateAccountInfo(this.props.accountInfo)); }, FormComponent.validateAccount(this.props.account));
} }
componentDidMount() { componentDidMount() {
@ -61,24 +61,36 @@ const CreatePageForForm = (FormComponent) => {
} }
onFieldChange = (event) => { onFieldChange = (event) => {
const changes = {}; const next = this.state.account.clone();
let val = event.target.value;
if (event.target.type === 'checkbox') { if (event.target.type === 'checkbox') {
changes[event.target.id] = event.target.checked; val = event.target.checked;
} else { }
changes[event.target.id] = event.target.value; if (event.target.id === 'emailAddress') {
if (event.target.id === 'email') { val = val.trim();
changes[event.target.id] = event.target.value.trim();
}
} }
const accountInfo = Object.assign({}, this.state.accountInfo, changes); if (event.target.id.includes('.')) {
const {errorFieldNames, errorMessage, populated} = FormComponent.validateAccountInfo(accountInfo); const [parent, key] = event.target.id.split('.');
next[parent][key] = val;
} else {
next[event.target.id] = val;
}
this.setState({accountInfo, errorFieldNames, errorMessage, populated, errorStatusCode: null}); const {errorFieldNames, errorMessage, populated} = FormComponent.validateAccount(next);
this.setState({
account: next,
errorFieldNames,
errorMessage,
populated,
errorStatusCode: null,
});
} }
onSubmit = () => { onSubmit = () => {
OnboardingActions.setAccountInfo(this.state.accountInfo); OnboardingActions.setAccount(this.state.account);
this._formEl.submit(); this._formEl.submit();
} }
@ -90,24 +102,24 @@ const CreatePageForForm = (FormComponent) => {
} }
onBack = () => { onBack = () => {
OnboardingActions.setAccountInfo(this.state.accountInfo); OnboardingActions.setAccount(this.state.account);
OnboardingActions.moveToPreviousPage(); OnboardingActions.moveToPreviousPage();
} }
onConnect = (updatedAccountInfo) => { onConnect = (updatedAccount) => {
const accountInfo = updatedAccountInfo || this.state.accountInfo; const account = updatedAccount || this.state.account;
this.setState({submitting: true}); this.setState({submitting: true});
buildAndValidateAccount(accountInfo) finalizeAndValidateAccount(account)
.then(({account}) => { .then((validated) => {
OnboardingActions.moveToPage('account-onboarding-success') OnboardingActions.moveToPage('account-onboarding-success')
OnboardingActions.accountJSONReceived(account) OnboardingActions.finishAndAddAccount(validated)
}) })
.catch((err) => { .catch((err) => {
Actions.recordUserEvent('Email Account Auth Failed', { Actions.recordUserEvent('Email Account Auth Failed', {
errorMessage: err.message, errorMessage: err.message,
provider: accountInfo.type, provider: account.provider,
}) })
const errorFieldNames = err.body ? (err.body.missing_fields || err.body.missing_settings || []) : [] const errorFieldNames = err.body ? (err.body.missing_fields || err.body.missing_settings || []) : []
@ -117,7 +129,7 @@ const CreatePageForForm = (FormComponent) => {
if (err.errorType === "setting_update_error") { if (err.errorType === "setting_update_error") {
errorMessage = 'The IMAP/SMTP servers for this account do not match our records. Please verify that any server names you entered are correct. If your IMAP/SMTP server has changed, first remove this account from Mailspring, then try logging in again.'; errorMessage = 'The IMAP/SMTP servers for this account do not match our records. Please verify that any server names you entered are correct. If your IMAP/SMTP server has changed, first remove this account from Mailspring, then try logging in again.';
} }
if (err.errorType && err.errorType.includes("autodiscover") && (accountInfo.type === 'exchange')) { if (err.errorType && err.errorType.includes("autodiscover") && (account.provider === 'exchange')) {
errorFieldNames.push('eas_server_host') errorFieldNames.push('eas_server_host')
errorFieldNames.push('username'); errorFieldNames.push('username');
} }
@ -144,8 +156,8 @@ const CreatePageForForm = (FormComponent) => {
} }
_renderButton() { _renderButton() {
const {accountInfo, submitting} = this.state; const {account, submitting} = this.state;
const buttonLabel = FormComponent.submitLabel(accountInfo); const buttonLabel = FormComponent.submitLabel(account);
// We're not on the last page. // We're not on the last page.
if (submitting) { if (submitting) {
@ -172,11 +184,11 @@ const CreatePageForForm = (FormComponent) => {
// help with common problems. For instance, they may need an app password, // help with common problems. For instance, they may need an app password,
// or to enable specific settings with their provider. // or to enable specific settings with their provider.
_renderCredentialsNote() { _renderCredentialsNote() {
const {errorStatusCode, accountInfo} = this.state; const {errorStatusCode, account} = this.state;
if (errorStatusCode !== 401) { return false; } if (errorStatusCode !== 401) { return false; }
let message; let message;
let articleURL; let articleURL;
if (accountInfo.email.includes("@yahoo.com")) { if (account.emailAddress.includes("@yahoo.com")) {
message = "Have you enabled access through Yahoo?"; message = "Have you enabled access through Yahoo?";
articleURL = "https://support.getmailspring.com/hc/en-us/articles/115001076128"; articleURL = "https://support.getmailspring.com/hc/en-us/articles/115001076128";
} else { } else {
@ -200,11 +212,11 @@ const CreatePageForForm = (FormComponent) => {
} }
render() { render() {
const {accountInfo, errorMessage, errorFieldNames, submitting} = this.state; const {account, errorMessage, errorFieldNames, submitting} = this.state;
const AccountType = AccountTypes.find(a => a.type === accountInfo.type); const providerConfig = AccountProviders.find(({provider}) => provider === account.provider);
if (!AccountType) { if (!providerConfig) {
throw new Error(`Cannot find account type ${accountInfo.type}`); throw new Error(`Cannot find account provider ${account.provider}`);
} }
const hideTitle = errorMessage && errorMessage.length > 120; const hideTitle = errorMessage && errorMessage.length > 120;
@ -213,21 +225,21 @@ const CreatePageForForm = (FormComponent) => {
<div className={`page account-setup ${FormComponent.displayName}`}> <div className={`page account-setup ${FormComponent.displayName}`}>
<div className="logo-container"> <div className="logo-container">
<RetinaImg <RetinaImg
style={{backgroundColor: AccountType.color, borderRadius: 44}} style={{backgroundColor: providerConfig.color, borderRadius: 44}}
name={AccountType.headerIcon} name={providerConfig.headerIcon}
mode={RetinaImg.Mode.ContentPreserve} mode={RetinaImg.Mode.ContentPreserve}
className="logo" className="logo"
/> />
</div> </div>
{hideTitle ? <div style={{height: 20}} /> : <h2>{FormComponent.titleLabel(AccountType)}</h2>} {hideTitle ? <div style={{height: 20}} /> : <h2>{FormComponent.titleLabel(providerConfig)}</h2>}
<FormErrorMessage <FormErrorMessage
message={errorMessage} message={errorMessage}
empty={FormComponent.subtitleLabel(AccountType)} empty={FormComponent.subtitleLabel(providerConfig)}
/> />
{ this._renderCredentialsNote() } { this._renderCredentialsNote() }
<FormComponent <FormComponent
ref={(el) => { this._formEl = el; }} ref={(el) => { this._formEl = el; }}
accountInfo={accountInfo} account={account}
errorFieldNames={errorFieldNames} errorFieldNames={errorFieldNames}
submitting={submitting} submitting={submitting}
onFieldChange={this.onFieldChange} onFieldChange={this.onFieldChange}

View file

@ -1,6 +1,12 @@
import React from 'react'; import React from 'react';
const FormField = (props) => { const FormField = (props) => {
const field = props.field;
let val = props.account[field];
if (props.field.includes('.')) {
const [parent, key] = props.field.split('.');
val = props.account[parent][key];
}
return ( return (
<span> <span>
<label htmlFor={props.field}>{props.title}:</label> <label htmlFor={props.field}>{props.title}:</label>
@ -8,9 +14,10 @@ const FormField = (props) => {
type={props.type || "text"} type={props.type || "text"}
id={props.field} id={props.field}
style={props.style} style={props.style}
className={(props.accountInfo[props.field] && props.errorFieldNames.includes(props.field)) ? 'error' : ''} className={(val && props.errorFieldNames.includes(props.field)) ? 'error' : ''}
disabled={props.submitting} disabled={props.submitting}
value={props.accountInfo[props.field] || ''} spellCheck="false"
value={val || ''}
onKeyPress={props.onFieldKeyPress} onKeyPress={props.onFieldKeyPress}
onChange={props.onFieldChange} onChange={props.onFieldChange}
/> />
@ -27,7 +34,7 @@ FormField.propTypes = {
onFieldKeyPress: React.PropTypes.func, onFieldKeyPress: React.PropTypes.func,
onFieldChange: React.PropTypes.func, onFieldChange: React.PropTypes.func,
errorFieldNames: React.PropTypes.array, errorFieldNames: React.PropTypes.array,
accountInfo: React.PropTypes.object, account: React.PropTypes.object,
} }
export default FormField; export default FormField;

View file

@ -1,12 +1,12 @@
import Reflux from 'reflux'; import Reflux from 'reflux';
const OnboardingActions = Reflux.createActions([ const OnboardingActions = Reflux.createActions([
"setAccountInfo",
"setAccountType",
"moveToPreviousPage", "moveToPreviousPage",
"moveToPage", "moveToPage",
"setAccount",
"chooseAccountProvider",
"identityJSONReceived", "identityJSONReceived",
"accountJSONReceived", "finishAndAddAccount",
]); ]);
for (const key of Object.keys(OnboardingActions)) { for (const key of Object.keys(OnboardingActions)) {

View file

@ -3,6 +3,7 @@
import crypto from 'crypto'; import crypto from 'crypto';
import {CommonProviderSettings} from 'imap-provider-settings'; import {CommonProviderSettings} from 'imap-provider-settings';
import { import {
Account,
NylasAPIRequest, NylasAPIRequest,
IdentityStore, IdentityStore,
RegExpUtils, RegExpUtils,
@ -11,22 +12,7 @@ import {
const {makeRequest, rootURLForServer} = NylasAPIRequest; const {makeRequest, rootURLForServer} = NylasAPIRequest;
const IMAP_FIELDS = new Set([ function base64URL(inBuffer) {
"imap_host",
"imap_port",
"imap_username",
"imap_password",
"imap_security",
"imap_allow_insecure_ssl",
"smtp_host",
"smtp_port",
"smtp_username",
"smtp_password",
"smtp_security",
"smtp_allow_insecure_ssl",
]);
function base64url(inBuffer) {
let buffer; let buffer;
if (typeof inBuffer === "string") { if (typeof inBuffer === "string") {
buffer = new Buffer(inBuffer); buffer = new Buffer(inBuffer);
@ -55,114 +41,92 @@ function idForAccount(emailAddress, connectionSettings) {
return crypto.createHash('sha256').update(idString, 'utf8').digest('hex'); return crypto.createHash('sha256').update(idString, 'utf8').digest('hex');
} }
export function makeGmailOAuthRequest(sessionKey) { export function expandAccountWithCommonSettings(account) {
return makeRequest({ const domain = account.emailAddress.split('@').pop().toLowerCase();
server: 'accounts', let template = CommonProviderSettings[domain] || CommonProviderSettings[account.provider] || {};
path: `/auth/gmail/token?key=${sessionKey}`,
method: 'GET',
auth: false,
});
}
export async function authIMAPForGmail(serverTokenResponse) {
// At this point, the Mailspring server has retrieved the Gmail token,
// created an account object in the database and tested it. All we
// need to do is save it locally, since we're confident Gmail will be
// accessible from the local sync worker.
const {emailAddress, refreshToken} = serverTokenResponse;
const settings = expandAccountInfoWithCommonSettings({email: emailAddress, refreshToken, type: 'gmail'});
return {
id: idForAccount(emailAddress, settings),
provider: 'gmail',
name,
settings,
emailAddress,
};
}
export function buildGmailSessionKey() {
return base64url(crypto.randomBytes(40));
}
export function buildGmailAuthURL(sessionKey) {
return `${rootURLForServer('accounts')}/auth/gmail?state=${sessionKey}`;
}
export async function buildAndValidateAccount(accountInfo) {
const {username, type, email, name} = accountInfo;
const data = {
id: idForAccount(email, accountInfo),
provider: type,
name: name,
emailAddress: email,
settings: Object.assign({}, accountInfo),
};
// handle special case for exchange/outlook/hotmail username field
data.settings.username = username || email;
if (data.settings.imap_port) {
data.settings.imap_port /= 1;
}
if (data.settings.smtp_port) {
data.settings.smtp_port /= 1;
}
// Only include the required IMAP fields. Auth validation does not allow extra fields
if (type !== "gmail") {
for (const key of Object.keys(data.settings)) {
if (!IMAP_FIELDS.has(key)) {
delete data.settings[key];
}
}
}
// Test the account locally - if it succeeds, send it to the server and test it there
const proc = new MailsyncProcess(NylasEnv.getLoadSettings(), IdentityStore.identity(), data);
const {account} = await proc.test();
return account;
}
export function isValidHost(value) {
return RegExpUtils.domainRegex().test(value) || RegExpUtils.ipAddressRegex().test(value);
}
export function expandAccountInfoWithCommonSettings(accountInfo) {
const {email, type} = accountInfo;
const domain = email.split('@').pop().toLowerCase();
let template = CommonProviderSettings[domain] || CommonProviderSettings[type] || {};
if (template.alias) { if (template.alias) {
template = CommonProviderSettings[template.alias]; template = CommonProviderSettings[template.alias];
} }
const usernameWithFormat = (format) => { const usernameWithFormat = (format) => {
if (format === 'email') { if (format === 'email') {
return email return account.emailAddress
} }
if (format === 'email-without-domain') { if (format === 'email-without-domain') {
return email.split('@').shift(); return account.emailAddress.split('@').shift();
} }
return undefined; return undefined;
} }
const defaults = { const populated = account.clone();
populated.settings = Object.assign({
imap_host: template.imap_host, imap_host: template.imap_host,
imap_port: template.imap_port || 993, imap_port: template.imap_port || 993,
imap_username: usernameWithFormat(template.imap_user_format), imap_username: usernameWithFormat(template.imap_user_format),
imap_password: accountInfo.password, imap_password: populated.settings.imap_password,
imap_security: template.imap_security || "SSL / TLS", imap_security: template.imap_security || "SSL / TLS",
imap_allow_insecure_ssl: template.imap_allow_insecure_ssl || false, imap_allow_insecure_ssl: template.imap_allow_insecure_ssl || false,
smtp_host: template.smtp_host, smtp_host: template.smtp_host,
smtp_port: template.smtp_port || 587, smtp_port: template.smtp_port || 587,
smtp_username: usernameWithFormat(template.smtp_user_format), smtp_username: usernameWithFormat(template.smtp_user_format),
smtp_password: accountInfo.password, smtp_password: populated.settings.smtp_password || populated.settings.imap_password,
smtp_security: template.smtp_security || "STARTTLS", smtp_security: template.smtp_security || "STARTTLS",
smtp_allow_insecure_ssl: template.smtp_allow_insecure_ssl || false, smtp_allow_insecure_ssl: template.smtp_allow_insecure_ssl || false,
}, populated.settings);
return populated;
}
export function makeGmailOAuthRequest(sessionKey) {
return makeRequest({
server: 'identity',
path: `/auth/gmail/token?key=${sessionKey}`,
method: 'GET',
auth: false,
});
}
export async function buildGmailAccountFromToken(serverTokenResponse) {
// At this point, the Mailspring server has retrieved the Gmail token,
// created an account object in the database and tested it. All we
// need to do is save it locally, since we're confident Gmail will be
// accessible from the local sync worker.
const {emailAddress, refreshToken} = serverTokenResponse;
const account = new Account();
account.emailAddress = emailAddress;
account.provider = 'gmail';
account.settings.refresh_token = refreshToken;
return expandAccountWithCommonSettings(account);
}
export function buildGmailSessionKey() {
return base64URL(crypto.randomBytes(40));
}
export function buildGmailAuthURL(sessionKey) {
return `${rootURLForServer('identity')}/auth/gmail?state=${sessionKey}`;
}
export async function finalizeAndValidateAccount(account) {
account.id = idForAccount(account.emailAddress, account.settings);
// handle special case for exchange/outlook/hotmail username field
account.settings.username = account.settings.username || account.settings.email;
if (account.settings.imap_port) {
account.settings.imap_port /= 1;
}
if (account.settings.smtp_port) {
account.settings.smtp_port /= 1;
} }
return Object.assign({}, accountInfo, defaults); // Test connections to IMAP and SMTP
const proc = new MailsyncProcess(NylasEnv.getLoadSettings(), IdentityStore.identity(), account);
const response = await proc.test();
return new Account(response.account);
}
export function isValidHost(value) {
return RegExpUtils.domainRegex().test(value) || RegExpUtils.ipAddressRegex().test(value);
} }

View file

@ -53,7 +53,7 @@ export default class OnboardingRoot extends React.Component {
return { return {
page: OnboardingStore.page(), page: OnboardingStore.page(),
pageDepth: OnboardingStore.pageDepth(), pageDepth: OnboardingStore.pageDepth(),
accountInfo: OnboardingStore.accountInfo(), account: OnboardingStore.account(),
}; };
} }
@ -79,7 +79,7 @@ export default class OnboardingRoot extends React.Component {
transitionEnterTimeout={150} transitionEnterTimeout={150}
> >
<div key={this.state.page} className="page-container"> <div key={this.state.page} className="page-container">
<Component accountInfo={this.state.accountInfo} /> <Component account={this.state.account} />
</div> </div>
</ReactCSSTransitionGroup> </ReactCSSTransitionGroup>
</div> </div>

View file

@ -1,65 +1,51 @@
import {AccountStore, Actions, IdentityStore} from 'nylas-exports'; import {AccountStore, Account, Actions, IdentityStore} from 'nylas-exports';
import {ipcRenderer} from 'electron'; import {ipcRenderer} from 'electron';
import NylasStore from 'nylas-store'; import NylasStore from 'nylas-store';
import OnboardingActions from './onboarding-actions'; import OnboardingActions from './onboarding-actions';
function accountTypeForProvider(provider) {
if (provider === 'eas') {
return 'exchange';
}
if (provider === 'custom') {
return 'imap';
}
return provider;
}
class OnboardingStore extends NylasStore { class OnboardingStore extends NylasStore {
constructor() { constructor() {
super(); super();
this.listenTo(OnboardingActions.moveToPreviousPage, this._onMoveToPreviousPage) this.listenTo(OnboardingActions.moveToPreviousPage, this._onMoveToPreviousPage)
this.listenTo(OnboardingActions.moveToPage, this._onMoveToPage) this.listenTo(OnboardingActions.moveToPage, this._onMoveToPage)
this.listenTo(OnboardingActions.accountJSONReceived, this._onAccountJSONReceived) this.listenTo(OnboardingActions.setAccount, this._onSetAccount);
this.listenTo(OnboardingActions.chooseAccountProvider, this._onChooseAccountProvider);
this.listenTo(OnboardingActions.finishAndAddAccount, this._onFinishAndAddAccount)
this.listenTo(OnboardingActions.identityJSONReceived, this._onIdentityJSONReceived) this.listenTo(OnboardingActions.identityJSONReceived, this._onIdentityJSONReceived)
this.listenTo(OnboardingActions.setAccountInfo, this._onSetAccountInfo);
this.listenTo(OnboardingActions.setAccountType, this._onSetAccountType); ipcRenderer.on('set-account-provider', (e, provider) => {
ipcRenderer.on('set-account-type', (e, type) => { if (provider) {
if (type) { this._onChooseAccountProvider(provider)
this._onSetAccountType(type)
} else { } else {
this._pageStack = ['account-choose'] this._pageStack = ['account-choose']
this.trigger() this.trigger()
} }
}) })
const {existingAccount, addingAccount, accountType} = NylasEnv.getWindowProps(); const {existingAccount, addingAccount, accountProvider} = NylasEnv.getWindowProps();
const hasAccounts = (AccountStore.accounts().length > 0) const hasAccounts = (AccountStore.accounts().length > 0)
const identity = IdentityStore.identity(); const identity = IdentityStore.identity();
if (identity) { this._account = new Account({
this._accountInfo = { name: identity ? `${identity.firstName || ""} ${identity.lastName || ""}` : '',
name: `${identity.firstName || ""} ${identity.lastName || ""}`, emailAddress: identity ? identity.emailAddress : '',
}; settings: {},
} else { });
this._accountInfo = {};
}
if (existingAccount) { if (existingAccount) {
// Used when re-adding an account after re-connecting // Used when re-adding an account after re-connecting
const existingAccountType = accountTypeForProvider(existingAccount.provider); this._pageStack = ['account-choose'];
this._pageStack = ['account-choose'] this._account.name = existingAccount.name;
this._accountInfo = { this._account.emailAddress = existingAccount.emailAddress;
name: existingAccount.name, this._onChooseAccountProvider(existingAccount.provider);
email: existingAccount.emailAddress,
};
this._onSetAccountType(existingAccountType);
} else if (addingAccount) { } else if (addingAccount) {
// Adding a new, unknown account // Adding a new, unknown account
this._pageStack = ['account-choose']; this._pageStack = ['account-choose'];
if (accountType) { if (accountProvider) {
this._onSetAccountType(accountType); this._onChooseAccountProvider(accountProvider);
} }
} else if (identity) { } else if (identity) {
// Should only happen if config was edited to remove all accounts, // Should only happen if config was edited to remove all accounts,
@ -88,26 +74,34 @@ class OnboardingStore extends NylasStore {
}, 100); }, 100);
} }
_onSetAccountType = (type) => { _onChooseAccountProvider = (provider) => {
let nextPage = "account-settings"; let nextPage = "account-settings";
if (type === 'gmail') { if (provider === 'gmail') {
nextPage = "account-settings-gmail"; nextPage = "account-settings-gmail";
} else if (type === 'exchange') { } else if (provider === 'exchange') {
nextPage = "account-settings-exchange"; nextPage = "account-settings-exchange";
} }
Actions.recordUserEvent('Selected Account Type', { Actions.recordUserEvent('Selected Account Provider', {
provider: type, provider,
}); });
// Don't carry over any type-specific account information // Don't carry over any type-specific account information
const {email, name, password} = this._accountInfo; this._onSetAccount(new Account({
this._onSetAccountInfo({email, name, password, type}); emailAddress: this._account.emailAddress,
name: this._account.name,
settings: {},
provider,
}));
this._onMoveToPage(nextPage); this._onMoveToPage(nextPage);
} }
_onSetAccountInfo = (info) => { _onSetAccount = (acct) => {
this._accountInfo = info; if (!(acct instanceof Account)) {
throw new Error("OnboardingActions.setAccount expects an Account instance.");
}
this._account = acct;
this.trigger(); this.trigger();
} }
@ -128,10 +122,10 @@ class OnboardingStore extends NylasStore {
setTimeout(() => { setTimeout(() => {
if (isFirstAccount) { if (isFirstAccount) {
this._onSetAccountInfo(Object.assign({}, this._accountInfo, { const next = this._account.clone();
name: `${json.firstName || ""} ${json.lastName || ""}`, next.name = `${json.firstName || ""} ${json.lastName || ""}`;
email: json.emailAddress, next.emailAddress = json.emailAddress;
})); this._onSetAccount(next);
OnboardingActions.moveToPage('account-choose'); OnboardingActions.moveToPage('account-choose');
} else { } else {
this._onOnboardingComplete(); this._onOnboardingComplete();
@ -139,22 +133,21 @@ class OnboardingStore extends NylasStore {
}, 1000); }, 1000);
} }
_onAccountJSONReceived = async (json) => { _onFinishAndAddAccount = async (account) => {
try { try {
const isFirstAccount = AccountStore.accounts().length === 0; const isFirstAccount = AccountStore.accounts().length === 0;
AccountStore.addAccountFromJSON(json);
AccountStore.addAccount(account);
NylasEnv.displayWindow();
Actions.recordUserEvent('Email Account Auth Succeeded', { Actions.recordUserEvent('Email Account Auth Succeeded', {
provider: json.provider, provider: account.provider,
}); });
ipcRenderer.send('new-account-added');
NylasEnv.displayWindow();
if (isFirstAccount) { if (isFirstAccount) {
this._onMoveToPage('initial-preferences'); this._onMoveToPage('initial-preferences');
Actions.recordUserEvent('First Account Linked', { Actions.recordUserEvent('First Account Linked', {
provider: json.provider, provider: account.provider,
}); });
} else { } else {
// let them see the "success" screen for a moment // let them see the "success" screen for a moment
@ -177,8 +170,8 @@ class OnboardingStore extends NylasStore {
return this._pageStack.length; return this._pageStack.length;
} }
accountInfo() { account() {
return this._accountInfo; return this._account;
} }
} }

View file

@ -1,30 +1,30 @@
import React from 'react'; import React from 'react';
import {RetinaImg} from 'nylas-component-kit'; import {RetinaImg} from 'nylas-component-kit';
import OnboardingActions from './onboarding-actions'; import OnboardingActions from './onboarding-actions';
import AccountTypes from './account-types'; import AccountProviders from './account-providers';
export default class AccountChoosePage extends React.Component { export default class AccountChoosePage extends React.Component {
static displayName = "AccountChoosePage"; static displayName = "AccountChoosePage";
static propTypes = { static propTypes = {
accountInfo: React.PropTypes.object, account: React.PropTypes.object,
} }
_renderAccountTypes() { _renderProviders() {
return AccountTypes.map((accountType) => return AccountProviders.map(({icon, displayName, provider}) =>
<div <div
key={accountType.type} key={provider}
className={`provider ${accountType.type}`} className={`provider ${provider}`}
onClick={() => OnboardingActions.setAccountType(accountType.type)} onClick={() => OnboardingActions.chooseAccountProvider(provider)}
> >
<div className="icon-container"> <div className="icon-container">
<RetinaImg <RetinaImg
name={accountType.icon} name={icon}
mode={RetinaImg.Mode.ContentPreserve} mode={RetinaImg.Mode.ContentPreserve}
className="icon" className="icon"
/> />
</div> </div>
<span className="provider-name">{accountType.displayName}</span> <span className="provider-name">{displayName}</span>
</div> </div>
); );
} }
@ -36,7 +36,7 @@ export default class AccountChoosePage extends React.Component {
Connect an email account Connect an email account
</h2> </h2>
<div className="provider-list"> <div className="provider-list">
{this._renderAccountTypes()} {this._renderProviders()}
</div> </div>
</div> </div>
); );

View file

@ -1,31 +1,32 @@
import React, {Component} from 'react'; import React, {Component} from 'react';
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import {RetinaImg} from 'nylas-component-kit'; import {RetinaImg} from 'nylas-component-kit';
import AccountTypes from './account-types' import AccountProviders from './account-providers'
class AccountOnboardingSuccess extends Component { // eslint-disable-line class AccountOnboardingSuccess extends Component { // eslint-disable-line
static displayName = 'AccountOnboardingSuccess' static displayName = 'AccountOnboardingSuccess'
static propTypes = { static propTypes = {
accountInfo: PropTypes.object, account: PropTypes.object,
} }
render() { render() {
const {accountInfo} = this.props const {account} = this.props;
const accountType = AccountTypes.find(a => a.type === accountInfo.type); const providerConfig = AccountProviders.find(({provider}) => provider === account.provider);
return ( return (
<div className={`page account-setup AccountOnboardingSuccess`}> <div className={`page account-setup AccountOnboardingSuccess`}>
<div className="logo-container"> <div className="logo-container">
<RetinaImg <RetinaImg
style={{backgroundColor: accountType.color, borderRadius: 44}} style={{backgroundColor: providerConfig.color, borderRadius: 44}}
name={accountType.headerIcon} name={providerConfig.headerIcon}
mode={RetinaImg.Mode.ContentPreserve} mode={RetinaImg.Mode.ContentPreserve}
className="logo" className="logo"
/> />
</div> </div>
<div> <div>
<h2>Successfully connected to {accountType.displayName}!</h2> <h2>Successfully connected to {providerConfig.displayName}!</h2>
<h3>Adding your account to Mailspring</h3> <h3>Adding your account to Mailspring</h3>
</div> </div>
</div> </div>

View file

@ -8,7 +8,7 @@ class AccountExchangeSettingsForm extends React.Component {
static displayName = 'AccountExchangeSettingsForm'; static displayName = 'AccountExchangeSettingsForm';
static propTypes = { static propTypes = {
accountInfo: React.PropTypes.object, account: React.PropTypes.object,
errorFieldNames: React.PropTypes.array, errorFieldNames: React.PropTypes.array,
submitting: React.PropTypes.bool, submitting: React.PropTypes.bool,
onConnect: React.PropTypes.func, onConnect: React.PropTypes.func,
@ -28,28 +28,28 @@ class AccountExchangeSettingsForm extends React.Component {
return 'Enter your Exchange credentials to get started.'; return 'Enter your Exchange credentials to get started.';
} }
static validateAccountInfo = (accountInfo) => { static validateAccount = (account) => {
const {email, password, name} = accountInfo; const {emailAddress, password, name} = account;
const errorFieldNames = []; const errorFieldNames = [];
let errorMessage = null; let errorMessage = null;
if (!email || !password || !name) { if (!emailAddress || !password || !name) {
return {errorMessage, errorFieldNames, populated: false}; return {errorMessage, errorFieldNames, populated: false};
} }
if (!RegExpUtils.emailRegex().test(accountInfo.email)) { if (!RegExpUtils.emailRegex().test(emailAddress)) {
errorFieldNames.push('email') errorFieldNames.push('email')
errorMessage = "Please provide a valid email address." errorMessage = "Please provide a valid email address."
} }
if (!accountInfo.password) { if (!account.settings.password) {
errorFieldNames.push('password') errorFieldNames.push('password')
errorMessage = "Please provide a password for your account." errorMessage = "Please provide a password for your account."
} }
if (!accountInfo.name) { if (!account.name) {
errorFieldNames.push('name') errorFieldNames.push('name')
errorMessage = "Please provide your name." errorMessage = "Please provide your name."
} }
if (accountInfo.eas_server_host && !isValidHost(accountInfo.eas_server_host)) { if (account.settings.eas_server_host && !isValidHost(account.settings.eas_server_host)) {
errorFieldNames.push('eas_server_host') errorFieldNames.push('eas_server_host')
errorMessage = "Please provide a valid host name." errorMessage = "Please provide a valid host name."
} }
@ -67,13 +67,13 @@ class AccountExchangeSettingsForm extends React.Component {
} }
render() { render() {
const {errorFieldNames, accountInfo} = this.props; const {errorFieldNames, account} = this.props;
const showAdvanced = ( const showAdvanced = (
this.state.showAdvanced || this.state.showAdvanced ||
errorFieldNames.includes('eas_server_host') || errorFieldNames.includes('eas_server_host') ||
errorFieldNames.includes('username') || errorFieldNames.includes('username') ||
accountInfo.eas_server_host || account.eas_server_host ||
accountInfo.username account.username
); );
let classnames = "twocol"; let classnames = "twocol";

View file

@ -3,20 +3,20 @@ import {OAuthSignInPage} from 'nylas-component-kit';
import { import {
makeGmailOAuthRequest, makeGmailOAuthRequest,
authIMAPForGmail, buildGmailAccountFromToken,
buildGmailSessionKey, buildGmailSessionKey,
buildGmailAuthURL, buildGmailAuthURL,
} from './onboarding-helpers'; } from './onboarding-helpers';
import OnboardingActions from './onboarding-actions'; import OnboardingActions from './onboarding-actions';
import AccountTypes from './account-types'; import AccountProviders from './account-providers';
export default class AccountSettingsPageGmail extends React.Component { export default class AccountSettingsPageGmail extends React.Component {
static displayName = "AccountSettingsPageGmail"; static displayName = "AccountSettingsPageGmail";
static propTypes = { static propTypes = {
accountInfo: React.PropTypes.object, account: React.PropTypes.object,
}; };
constructor() { constructor() {
@ -30,9 +30,10 @@ export default class AccountSettingsPageGmail extends React.Component {
} }
render() { render() {
const {accountInfo} = this.props; const providerConfig = AccountProviders.find(a =>
const accountType = AccountTypes.find(a => a.type === accountInfo.type) a.provider === this.props.account.provider
const {headerIcon} = accountType; )
const {headerIcon} = providerConfig;
const goBack = () => OnboardingActions.moveToPreviousPage() const goBack = () => OnboardingActions.moveToPreviousPage()
return ( return (
@ -41,7 +42,7 @@ export default class AccountSettingsPageGmail extends React.Component {
providerAuthPageUrl={this._gmailAuthUrl} providerAuthPageUrl={this._gmailAuthUrl}
iconName={headerIcon} iconName={headerIcon}
tokenRequestPollFn={makeGmailOAuthRequest} tokenRequestPollFn={makeGmailOAuthRequest}
accountFromTokenFn={authIMAPForGmail} accountFromTokenFn={buildGmailAccountFromToken}
onSuccess={this.onSuccess} onSuccess={this.onSuccess}
onTryAgain={goBack} onTryAgain={goBack}
sessionKey={this._sessionKey} sessionKey={this._sessionKey}

View file

@ -7,7 +7,7 @@ class AccountIMAPSettingsForm extends React.Component {
static displayName = 'AccountIMAPSettingsForm'; static displayName = 'AccountIMAPSettingsForm';
static propTypes = { static propTypes = {
accountInfo: React.PropTypes.object, account: React.PropTypes.object,
errorFieldNames: React.PropTypes.array, errorFieldNames: React.PropTypes.array,
submitting: React.PropTypes.bool, submitting: React.PropTypes.bool,
onConnect: React.PropTypes.func, onConnect: React.PropTypes.func,
@ -27,24 +27,19 @@ class AccountIMAPSettingsForm extends React.Component {
return 'Complete the IMAP and SMTP settings below to connect your account.'; return 'Complete the IMAP and SMTP settings below to connect your account.';
} }
static validateAccountInfo = (accountInfo) => { static validateAccount = (account) => {
let errorMessage = null; let errorMessage = null;
const errorFieldNames = []; const errorFieldNames = [];
for (const type of ['imap', 'smtp']) { for (const type of ['imap', 'smtp']) {
if (!accountInfo[`${type}_host`] || !accountInfo[`${type}_username`] || !accountInfo[`${type}_password`]) { if (!account.settings[`${type}_host`] || !account.settings[`${type}_username`] || !account.settings[`${type}_password`]) {
return {errorMessage, errorFieldNames, populated: false}; return {errorMessage, errorFieldNames, populated: false};
} }
if (!isValidHost(accountInfo[`${type}_host`])) { if (!isValidHost(account.settings[`${type}_host`])) {
errorMessage = "Please provide a valid hostname or IP adddress."; errorMessage = "Please provide a valid hostname or IP adddress.";
errorFieldNames.push(`${type}_host`); errorFieldNames.push(`${type}_host`);
} }
// todo bg if (!Number.isInteger(account.settings[`${type}_port`] / 1)) {
// if (accountInfo[`${type}_host`] === 'imap.gmail.com') {
// errorMessage = "Please link Gmail accounts by choosing 'Google' on the account type screen.";
// errorFieldNames.push(`${type}_host`);
// }
if (!Number.isInteger(accountInfo[`${type}_port`] / 1)) {
errorMessage = "Please provide a valid port number."; errorMessage = "Please provide a valid port number.";
errorFieldNames.push(`${type}_port`); errorFieldNames.push(`${type}_port`);
} }
@ -61,16 +56,16 @@ class AccountIMAPSettingsForm extends React.Component {
if (!["imap", "smtp"].includes(protocol)) { if (!["imap", "smtp"].includes(protocol)) {
throw new Error(`Can't render port dropdown for protocol '${protocol}'`); throw new Error(`Can't render port dropdown for protocol '${protocol}'`);
} }
const {accountInfo, submitting, onFieldKeyPress, onFieldChange} = this.props; const {account: {settings}, submitting, onFieldKeyPress, onFieldChange} = this.props;
if (protocol === "imap") { if (protocol === "imap") {
return ( return (
<span> <span>
<label htmlFor="imap_port">Port:</label> <label htmlFor="settings.imap_port">Port:</label>
<select <select
id="imap_port" id="settings.imap_port"
tabIndex={0} tabIndex={0}
value={accountInfo.imap_port} value={settings.imap_port}
disabled={submitting} disabled={submitting}
onKeyPress={onFieldKeyPress} onKeyPress={onFieldKeyPress}
onChange={onFieldChange} onChange={onFieldChange}
@ -84,11 +79,11 @@ class AccountIMAPSettingsForm extends React.Component {
if (protocol === "smtp") { if (protocol === "smtp") {
return ( return (
<span> <span>
<label htmlFor="smtp_port">Port:</label> <label htmlFor="settings.smtp_port">Port:</label>
<select <select
id="smtp_port" id="settings.smtp_port"
tabIndex={0} tabIndex={0}
value={accountInfo.smtp_port} value={settings.smtp_port}
disabled={submitting} disabled={submitting}
onKeyPress={onFieldKeyPress} onKeyPress={onFieldKeyPress}
onChange={onFieldChange} onChange={onFieldChange}
@ -104,16 +99,18 @@ class AccountIMAPSettingsForm extends React.Component {
} }
renderSecurityDropdown(protocol) { renderSecurityDropdown(protocol) {
const {accountInfo, submitting, onFieldKeyPress, onFieldChange} = this.props; const {account: {settings}, submitting, onFieldKeyPress, onFieldChange} = this.props;
return ( return (
<div> <div>
<span> <span>
<label htmlFor={`${protocol}_security`}>Security:</label> <label htmlFor={`settings.${protocol}_security`}>
Security:
</label>
<select <select
id={`${protocol}_security`} id={`settings.${protocol}_security`}
tabIndex={0} tabIndex={0}
value={accountInfo[`${protocol}_security`]} value={settings[`${protocol}_security`]}
disabled={submitting} disabled={submitting}
onKeyPress={onFieldKeyPress} onKeyPress={onFieldKeyPress}
onChange={onFieldChange} onChange={onFieldChange}
@ -126,9 +123,9 @@ class AccountIMAPSettingsForm extends React.Component {
<span style={{paddingLeft: '20px', paddingTop: '10px'}}> <span style={{paddingLeft: '20px', paddingTop: '10px'}}>
<input <input
type="checkbox" type="checkbox"
id={`${protocol}_allow_insecure_ssl`} id={`settings.${protocol}_allow_insecure_ssl`}
disabled={submitting} disabled={submitting}
checked={accountInfo[`${protocol}_allow_insecure_ssl`] || false} checked={settings[`${protocol}_allow_insecure_ssl`] || false}
onKeyPress={onFieldKeyPress} onKeyPress={onFieldKeyPress}
onChange={onFieldChange} onChange={onFieldChange}
/> />
@ -141,13 +138,13 @@ class AccountIMAPSettingsForm extends React.Component {
renderFieldsForType(type) { renderFieldsForType(type) {
return ( return (
<div> <div>
<FormField field={`${type}_host`} title={"Server"} {...this.props} /> <FormField field={`settings.${type}_host`} title={"Server"} {...this.props} />
<div style={{textAlign: 'left'}}> <div style={{textAlign: 'left'}}>
{this.renderPortDropdown(type)} {this.renderPortDropdown(type)}
{this.renderSecurityDropdown(type)} {this.renderSecurityDropdown(type)}
</div> </div>
<FormField field={`${type}_username`} title={"Username"} {...this.props} /> <FormField field={`settings.${type}_username`} title={"Username"} {...this.props} />
<FormField field={`${type}_password`} title={"Password"} type="password" {...this.props} /> <FormField field={`settings.${type}_password`} title={"Password"} type="password" {...this.props} />
</div> </div>
); );
} }

View file

@ -3,14 +3,14 @@ import {RegExpUtils} from 'nylas-exports';
import OnboardingActions from './onboarding-actions'; import OnboardingActions from './onboarding-actions';
import CreatePageForForm from './decorators/create-page-for-form'; import CreatePageForForm from './decorators/create-page-for-form';
import {expandAccountInfoWithCommonSettings} from './onboarding-helpers'; import {expandAccountWithCommonSettings} from './onboarding-helpers';
import FormField from './form-field'; import FormField from './form-field';
class AccountBasicSettingsForm extends React.Component { class AccountBasicSettingsForm extends React.Component {
static displayName = 'AccountBasicSettingsForm'; static displayName = 'AccountBasicSettingsForm';
static propTypes = { static propTypes = {
accountInfo: React.PropTypes.object, account: React.PropTypes.object,
errorFieldNames: React.PropTypes.array, errorFieldNames: React.PropTypes.array,
submitting: React.PropTypes.bool, submitting: React.PropTypes.bool,
onConnect: React.PropTypes.func, onConnect: React.PropTypes.func,
@ -18,53 +18,52 @@ class AccountBasicSettingsForm extends React.Component {
onFieldKeyPress: React.PropTypes.func, onFieldKeyPress: React.PropTypes.func,
}; };
static submitLabel = (accountInfo) => { static submitLabel = (account) => {
return (accountInfo.type === 'imap') ? 'Continue' : 'Connect Account'; return (account.provider === 'imap') ? 'Continue' : 'Connect Account';
} }
static titleLabel = (AccountType) => { static titleLabel = (providerConfig) => {
return AccountType.title || `Add your ${AccountType.displayName} account`; return providerConfig.title || `Add your ${providerConfig.displayName} account`;
} }
static subtitleLabel = () => { static subtitleLabel = () => {
return 'Enter your email account credentials to get started.'; return `Enter your email account credentials to get started. Mailspring\nstores your email password securely and it is never sent to our servers.`;
} }
static validateAccountInfo = (accountInfo) => { static validateAccount = (account) => {
const {email, password, name} = accountInfo;
const errorFieldNames = []; const errorFieldNames = [];
let errorMessage = null; let errorMessage = null;
if (!email || !password || !name) { if (!account.emailAddress || !account.settings.imap_password || !account.name) {
return {errorMessage, errorFieldNames, populated: false}; return {errorMessage, errorFieldNames, populated: false};
} }
if (!RegExpUtils.emailRegex().test(accountInfo.email)) { if (!RegExpUtils.emailRegex().test(account.emailAddress)) {
errorFieldNames.push('email') errorFieldNames.push('email')
errorMessage = "Please provide a valid email address." errorMessage = "Please provide a valid email address."
} }
if (!accountInfo.password) { if (!account.name) {
errorFieldNames.push('password')
errorMessage = "Please provide a password for your account."
}
if (!accountInfo.name) {
errorFieldNames.push('name') errorFieldNames.push('name')
errorMessage = "Please provide your name." errorMessage = "Please provide your name."
} }
if (!account.settings.imap_password) {
errorFieldNames.push('password')
errorMessage = "Please provide a password for your account."
}
return {errorMessage, errorFieldNames, populated: true}; return {errorMessage, errorFieldNames, populated: true};
} }
submit() { submit() {
const accountInfo = expandAccountInfoWithCommonSettings(this.props.accountInfo); const account = expandAccountWithCommonSettings(this.props.account);
OnboardingActions.setAccountInfo(accountInfo); OnboardingActions.setAccount(account);
if (this.props.accountInfo.type === 'imap') { if (this.props.account.provider === 'imap') {
OnboardingActions.moveToPage('account-settings-imap'); OnboardingActions.moveToPage('account-settings-imap');
} else { } else {
// We have to pass in the updated accountInfo, because the onConnect() // We have to pass in the updated account, because the onConnect()
// we're calling exists on a component that won't have had it's state // we're calling exists on a component that won't have had it's state
// updated from the OnboardingStore change yet. // updated from the OnboardingStore change yet.
this.props.onConnect(accountInfo); this.props.onConnect(account);
} }
} }
@ -72,8 +71,8 @@ class AccountBasicSettingsForm extends React.Component {
return ( return (
<form className="settings"> <form className="settings">
<FormField field="name" title="Name" {...this.props} /> <FormField field="name" title="Name" {...this.props} />
<FormField field="email" title="Email" {...this.props} /> <FormField field="emailAddress" title="Email" {...this.props} />
<FormField field="password" title="Password" type="password" {...this.props} /> <FormField field="settings.imap_password" title="Password" type="password" {...this.props} />
</form> </form>
) )
} }

View file

@ -7,7 +7,7 @@ export default class AuthenticatePage extends React.Component {
static displayName = "AuthenticatePage"; static displayName = "AuthenticatePage";
static propTypes = { static propTypes = {
accountInfo: React.PropTypes.object, account: React.PropTypes.object,
}; };
_src() { _src() {

View file

@ -100,6 +100,7 @@
margin-bottom:15px; margin-bottom:15px;
max-width: 600px; max-width: 600px;
margin: auto; margin: auto;
white-space: pre-wrap;
&.error { &.error {
color: #A33; color: #A33;
@ -116,6 +117,9 @@
form.settings { form.settings {
padding: 0 20px; padding: 0 20px;
padding-bottom: 20px; padding-bottom: 20px;
span:last-child input {
margin-bottom:0;
}
} }
input { input {
display: inline-block; display: inline-block;
@ -169,10 +173,6 @@
text-align: right; text-align: right;
padding: 0; padding: 0;
} }
.btn {
margin-top:8px;
}
} }
.page.authenticate { .page.authenticate {
@ -339,7 +339,7 @@
} }
.twocol { .twocol {
padding-top: 20px; padding-top: 20px;
padding-bottom: 10px; padding-bottom: 20px;
} }
} }
.page.account-setup.google, .page.account-setup.AccountOnboardingSuccess { .page.account-setup.google, .page.account-setup.AccountOnboardingSuccess {

View file

@ -3,7 +3,7 @@
"main": "./lib/main", "main": "./lib/main",
"version": "0.1.0", "version": "0.1.0",
"serverUrl": { "serverUrl": {
"development": "http://localhost:5100", "development": "http://localhost:5101",
"staging": "https://link-staging.getmailspring.com", "staging": "https://link-staging.getmailspring.com",
"production": "https://link.getmailspring.com" "production": "https://link.getmailspring.com"
}, },

View file

@ -194,12 +194,12 @@ export function getLatestMessageWithReminder(thread, messages) {
} }
export function getReminderLabel(reminderDate, {fromNow = false, shortFormat = false} = {}) { export function getReminderLabel(reminderDate, {fromNow = false, shortFormat = false} = {}) {
const momentDate = DateUtils.futureDateFromString(reminderDate); const momentDate = moment(reminderDate);
if (shortFormat) { if (shortFormat) {
return momentDate ? `in ${momentDate.fromNow(true)}` : 'now' return momentDate ? `in ${momentDate.fromNow(true)}` : 'now'
} }
if (fromNow) { if (fromNow) {
return momentDate ? `Reminder set for ${momentDate.fromNow(true)} from now` : `Reminder set`; return momentDate ? `Reminder set for ${momentDate.fromNow(true)} from now` : `Reminder set`;
} }
return moment(reminderDate).format(DATE_FORMAT_LONG_NO_YEAR) return momentDate.format(DATE_FORMAT_LONG_NO_YEAR)
} }

View file

@ -1,4 +1,3 @@
import _ from 'underscore';
import NylasStore from 'nylas-store'; import NylasStore from 'nylas-store';
import { import {
@ -7,7 +6,6 @@ import {
Actions, Actions,
DatabaseStore, DatabaseStore,
Message, Message,
CategoryStore,
} from 'nylas-exports'; } from 'nylas-exports';
import SnoozeUtils from './snooze-utils' import SnoozeUtils from './snooze-utils'

View file

@ -2,7 +2,7 @@
"name": "thread-sharing", "name": "thread-sharing",
"version": "0.1.0", "version": "0.1.0",
"serverUrl": { "serverUrl": {
"development": "http://localhost:5100", "development": "http://localhost:5101",
"staging": "https://share-staging.getmailspring.com", "staging": "https://share-staging.getmailspring.com",
"production": "https://share.getmailspring.com" "production": "https://share.getmailspring.com"
}, },

View file

@ -288,18 +288,18 @@ export default class Application extends EventEmitter {
win.browserWindow.inspectElement(x, y); win.browserWindow.inspectElement(x, y);
}); });
this.on('application:add-account', ({existingAccount, accountType} = {}) => { this.on('application:add-account', ({existingAccount, accountProvider} = {}) => {
const onboarding = this.windowManager.get(WindowManager.ONBOARDING_WINDOW); const onboarding = this.windowManager.get(WindowManager.ONBOARDING_WINDOW);
if (onboarding) { if (onboarding) {
if (onboarding.browserWindow.webContents) { if (onboarding.browserWindow.webContents) {
onboarding.browserWindow.webContents.send('set-account-type', accountType) onboarding.browserWindow.webContents.send('set-account-provider', accountProvider)
} }
onboarding.show(); onboarding.show();
onboarding.focus(); onboarding.focus();
} else { } else {
this.windowManager.ensureWindow(WindowManager.ONBOARDING_WINDOW, { this.windowManager.ensureWindow(WindowManager.ONBOARDING_WINDOW, {
title: "Add an Account", title: "Add an Account",
windowProps: { addingAccount: true, existingAccount, accountType }, windowProps: { addingAccount: true, existingAccount, accountProvider },
}); });
} }
}); });
@ -526,11 +526,6 @@ export default class Application extends EventEmitter {
} }
}); });
ipcMain.on('new-account-added', () => {
// TODO BEN
// this.windowManager.ensureWindow(WindowManager.WORK_WINDOW)
});
ipcMain.on('run-in-window', (event, params) => { ipcMain.on('run-in-window', (event, params) => {
const sourceWindow = BrowserWindow.fromWebContents(event.sender); const sourceWindow = BrowserWindow.fromWebContents(event.sender);
this._sourceWindows = this._sourceWindows || {}; this._sourceWindows = this._sourceWindows || {};

View file

@ -30,10 +30,11 @@ The value of this attribute is always an array of other model objects.
Section: Database Section: Database
*/ */
export default class AttributeCollection extends Attribute { export default class AttributeCollection extends Attribute {
constructor({modelKey, jsonKey, itemClass, joinOnField, joinQueryableBy, queryable}) { constructor({modelKey, jsonKey, itemClass, joinOnField, joinQueryableBy, joinTableName, queryable}) {
super({modelKey, jsonKey, queryable}); super({modelKey, jsonKey, queryable});
this.itemClass = itemClass; this.itemClass = itemClass;
this.joinOnField = joinOnField; this.joinOnField = joinOnField;
this.joinTableName = joinTableName;
this.joinQueryableBy = joinQueryableBy || []; this.joinQueryableBy = joinQueryableBy || [];
} }
@ -68,6 +69,12 @@ export default class AttributeCollection extends Attribute {
}); });
} }
// Private: The Matcher interface uses this method to determine how to
// constuct a SQL join:
tableNameForJoinAgainst(primaryKlass) {
return this.joinTableName || `${primaryKlass.name}${this.itemClass.name}`;
}
// Public: Returns a {Matcher} for objects containing the provided value. // Public: Returns a {Matcher} for objects containing the provided value.
contains(val) { contains(val) {
this._assertPresentAndQueryable('contains', val); this._assertPresentAndQueryable('contains', val);

View file

@ -1,4 +1,3 @@
import {tableNameForJoin} from '../models/utils';
import LocalSearchQueryBackend from '../../services/search/search-query-backend-local' import LocalSearchQueryBackend from '../../services/search/search-query-backend-local'
// https://www.sqlite.org/faq.html#q14 // https://www.sqlite.org/faq.html#q14
@ -115,7 +114,7 @@ class Matcher {
switch (this.comparator) { switch (this.comparator) {
case 'contains': case 'contains':
case 'containsAny': { case 'containsAny': {
const joinTable = tableNameForJoin(klass, this.attr.itemClass); const joinTable = this.attr.tableNameForJoinAgainst(klass);
const joinTableRef = this.joinTableRef(); const joinTableRef = this.joinTableRef();
return `INNER JOIN \`${joinTable}\` AS \`${joinTableRef}\` ON \`${joinTableRef}\`.\`id\` = \`${klass.name}\`.\`id\``; return `INNER JOIN \`${joinTable}\` AS \`${joinTableRef}\` ON \`${joinTableRef}\`.\`id\` = \`${klass.name}\`.\`id\``;
} }

View file

@ -191,7 +191,7 @@ export default class MailsyncBridge {
// Private // Private
_launchClient(account, {force} = {}) { _launchClient(account, {force} = {}) {
const fullAccountJSON = KeyManager.insertAccountSecrets(account.toJSON()); const fullAccountJSON = KeyManager.insertAccountSecrets(account).toJSON();
const identity = IdentityStore.identity(); const identity = IdentityStore.identity();
const id = account.id; const id = account.id;

View file

@ -47,8 +47,9 @@ export default class ModelWithMetadata extends Model {
static attributes = Object.assign({}, Model.attributes, { static attributes = Object.assign({}, Model.attributes, {
pluginMetadata: Attributes.Collection({ pluginMetadata: Attributes.Collection({
queryable: true, queryable: true,
joinOnField: 'pluginId',
itemClass: PluginMetadata, itemClass: PluginMetadata,
joinOnField: 'pluginId',
joinTableName: 'ModelPluginMetadata',
modelKey: 'pluginMetadata', modelKey: 'pluginMetadata',
jsonKey: 'metadata', jsonKey: 'metadata',
}), }),
@ -70,7 +71,11 @@ export default class ModelWithMetadata extends Model {
if (!metadata) { if (!metadata) {
return null; return null;
} }
return JSON.parse(JSON.stringify(metadata.value)); const m = JSON.parse(JSON.stringify(metadata.value));
if (m.expiration) {
m.expiration = new Date(m.expiration * 1000);
}
return m;
} }
// Private helpers // Private helpers
@ -88,7 +93,10 @@ export default class ModelWithMetadata extends Model {
metadata = new PluginMetadata({pluginId}); metadata = new PluginMetadata({pluginId});
this.pluginMetadata.push(metadata); this.pluginMetadata.push(metadata);
} }
metadata.value = metadataValue; metadata.value = Object.assign({}, metadataValue);
if (metadata.value.expiration) {
metadata.value.expiration = Math.round(new Date(metadata.value.expiration).getTime() / 1000);
}
return this; return this;
} }

View file

@ -409,7 +409,7 @@ export default class ModelQuery {
_subselectSQL(returningMatcher, subselectMatchers, order, limit) { _subselectSQL(returningMatcher, subselectMatchers, order, limit) {
const returningAttribute = returningMatcher.attribute() const returningAttribute = returningMatcher.attribute()
const table = Utils.tableNameForJoin(this._klass, returningAttribute.itemClass); const table = returningAttribute.tableNameForJoinAgainst(this._klass);
const wheres = subselectMatchers.map(c => c.whereSQL(this._klass)).filter(c => !!c); const wheres = subselectMatchers.map(c => c.whereSQL(this._klass)).filter(c => !!c);
let innerSQL = `SELECT \`id\` FROM \`${table}\` WHERE ${wheres.join(' AND ')} ${order} ${limit}`; let innerSQL = `SELECT \`id\` FROM \`${table}\` WHERE ${wheres.join(' AND ')} ${order} ${limit}`;

View file

@ -159,9 +159,6 @@ Utils =
return false unless id and _.isString(id) return false unless id and _.isString(id)
id[0..5] is 'local-' id[0..5] is 'local-'
tableNameForJoin: (primaryKlass, secondaryKlass) ->
"#{primaryKlass.name}#{secondaryKlass.name}"
imageNamed: (fullname, resourcePath) -> imageNamed: (fullname, resourcePath) ->
[name, ext] = fullname.split('.') [name, ext] = fullname.split('.')

View file

@ -25,14 +25,6 @@ export function rootURLForServer(server) {
production: "https://id.getmailspring.com", production: "https://id.getmailspring.com",
}[env]; }[env];
} }
if (server === 'accounts') {
return {
development: "http://localhost:5100",
staging: "https://accounts-staging.getmailspring.com",
production: "https://accounts.getmailspring.com",
}[env];
}
throw new Error("rootURLForServer: You must provide a valid `server` value"); throw new Error("rootURLForServer: You must provide a valid `server` value");
} }

View file

@ -203,28 +203,29 @@ class AccountStore extends NylasStore {
this._save() this._save()
} }
addAccountFromJSON = (json) => { addAccount = (account) => {
if (!json.emailAddress || !json.provider) { if (!account.emailAddress || !account.provider || !(account instanceof Account)) {
throw new Error(`Returned account data is invalid: ${JSON.stringify(json)}`) throw new Error(`Returned account data is invalid: ${JSON.stringify(account)}`)
} }
// send the account JSON and cloud token to the KeyManager, // send the account JSON and cloud token to the KeyManager,
// which gives us back a version with no secrets. // which gives us back a version with no secrets.
const cleanJSON = KeyManager.extractAccountSecrets(json); const cleanAccount = KeyManager.extractAccountSecrets(account);
this._loadAccounts(); this._loadAccounts();
const existingIdx = this._accounts.findIndex((a) => const existingIdx = this._accounts.findIndex((a) =>
a.id === cleanJSON.id || a.emailAddress === cleanJSON.emailAddress a.id === cleanAccount.id || a.emailAddress === cleanAccount.emailAddress
); );
if (existingIdx === -1) { if (existingIdx === -1) {
const account = (new Account()).fromJSON(cleanJSON); this._accounts.push(cleanAccount);
this._accounts.push(account);
} else { } else {
const account = this._accounts[existingIdx]; const existing = this._accounts[existingIdx];
account.syncState = Account.SYNC_STATE_OK; existing.syncState = Account.SYNC_STATE_OK;
account.fromJSON(cleanJSON); existing.name = cleanAccount.name;
existing.emailAddress = cleanAccount.emailAddress;
existing.settings = cleanAccount.settings;
} }
this._save(); this._save();

View file

@ -23,6 +23,11 @@ export default class SyncbackMetadataTask extends Task {
constructor(data = {}) { constructor(data = {}) {
super(data); super(data);
if (data.value && data.value.expiration) {
data.value.expiration = Math.round(new Date(data.value.expiration).getTime() / 1000);
}
if (data.model) { if (data.model) {
this.modelId = data.model.id; this.modelId = data.model.id;
this.modelClassName = data.model.constructor.name.toLowerCase(); this.modelClassName = data.model.constructor.name.toLowerCase();

View file

@ -33,27 +33,27 @@ class KeyManager {
}); });
} }
extractAccountSecrets(accountJSON) { extractAccountSecrets(account) {
const next = Object.assign({}, accountJSON); const next = account.clone();
this._try(() => { this._try(() => {
const keys = this._getKeyHash(); const keys = this._getKeyHash();
keys[`${accountJSON.emailAddress}-imap`] = next.settings.imap_password; keys[`${account.emailAddress}-imap`] = next.settings.imap_password;
delete next.settings.imap_password; delete next.settings.imap_password;
keys[`${accountJSON.emailAddress}-smtp`] = next.settings.smtp_password; keys[`${account.emailAddress}-smtp`] = next.settings.smtp_password;
delete next.settings.smtp_password; delete next.settings.smtp_password;
keys[`${accountJSON.emailAddress}-refresh-token`] = next.settings.refresh_token; keys[`${account.emailAddress}-refresh-token`] = next.settings.refresh_token;
delete next.settings.refresh_token; delete next.settings.refresh_token;
return this._writeKeyHash(keys); return this._writeKeyHash(keys);
}); });
return next; return next;
} }
insertAccountSecrets(accountJSON) { insertAccountSecrets(account) {
const next = Object.assign({}, accountJSON); const next = account.clone();
const keys = this._getKeyHash(); const keys = this._getKeyHash();
next.settings.imap_password = keys[`${accountJSON.emailAddress}-imap`]; next.settings.imap_password = keys[`${account.emailAddress}-imap`];
next.settings.smtp_password = keys[`${accountJSON.emailAddress}-smtp`]; next.settings.smtp_password = keys[`${account.emailAddress}-smtp`];
next.settings.refresh_token = keys[`${accountJSON.emailAddress}-refresh-token`]; next.settings.refresh_token = keys[`${account.emailAddress}-refresh-token`];
return next; return next;
} }

View file

@ -53,12 +53,10 @@ export default class MailsyncProcess extends EventEmitter {
const env = { const env = {
CONFIG_DIR_PATH: this.configDirPath, CONFIG_DIR_PATH: this.configDirPath,
IDENTITY_SERVER: 'unknown', IDENTITY_SERVER: 'unknown',
ACCOUNTS_SERVER: 'unknown',
}; };
if (process.type === 'renderer') { if (process.type === 'renderer') {
const rootURLForServer = require('./flux/nylas-api-request').rootURLForServer; const rootURLForServer = require('./flux/nylas-api-request').rootURLForServer;
env.IDENTITY_SERVER = rootURLForServer('identity'); env.IDENTITY_SERVER = rootURLForServer('identity');
env.ACCOUNTS_SERVER = rootURLForServer('accounts');
} }
this._proc = spawn(this.binaryPath, [`--mode`, mode], {env}); this._proc = spawn(this.binaryPath, [`--mode`, mode], {env});