Просмотр исходного кода

Merge branch 'feat/transplant-tabs-to-modal-for-master-merge' into imprv/transplant-gw3735

# Conflicts:
#	src/client/js/components/Page/PageManagement.jsx
白石誠 5 лет назад
Родитель
Сommit
b89f0c0b5b
29 измененных файлов с 822 добавлено и 467 удалено
  1. 14 2
      CHANGES.md
  2. 1 1
      package.json
  3. 8 7
      resource/locales/en_US/admin/admin.json
  4. 2 1
      resource/locales/en_US/translation.json
  5. 8 7
      resource/locales/ja_JP/admin/admin.json
  6. 2 1
      resource/locales/ja_JP/translation.json
  7. 11 9
      resource/locales/zh_CN/admin/admin.json
  8. 15 14
      resource/locales/zh_CN/translation.json
  9. 1 1
      src/client/js/app.jsx
  10. 0 3
      src/client/js/components/Admin/App/AwsSetting.jsx
  11. 70 169
      src/client/js/components/Admin/App/MailSetting.jsx
  12. 68 0
      src/client/js/components/Admin/App/SesSetting.jsx
  13. 89 0
      src/client/js/components/Admin/App/SmtpSetting.jsx
  14. 22 0
      src/client/js/components/Icons/PresentationIcon.jsx
  15. 2 1
      src/client/js/components/Page/PageManagement.jsx
  16. 15 21
      src/client/js/components/RecentCreated/RecentCreated.jsx
  17. 1 4
      src/client/js/components/ShareLink/ShareLink.jsx
  18. 58 38
      src/client/js/services/AdminAppContainer.js
  19. 68 0
      src/migrations/20200828024025-copy-aws-setting.js
  20. 50 0
      src/migrations/20200901034313-update-mail-transmission.js
  21. 34 0
      src/migrations/20200901034314-update-mail-transmission-fix.js
  22. 108 87
      src/server/routes/apiv3/app-settings.js
  23. 11 1
      src/server/routes/apiv3/attachment.js
  24. 40 0
      src/server/routes/apiv3/revisions.js
  25. 71 0
      src/server/routes/apiv3/users.js
  26. 0 1
      src/server/routes/index.js
  27. 13 89
      src/server/routes/page.js
  28. 32 8
      src/server/service/mail.js
  29. 8 2
      src/test/models/shareLink.test.js

+ 14 - 2
CHANGES.md

@@ -1,6 +1,18 @@
 # CHANGES
 
-## v4.1.3-RC
+## v4.1.6-RC
+
+* 
+
+## v4.1.5
+
+* Feature: Independent S3 configuration and SES configuration for AWS
+* Fix: Author name does not displayed in page history
+* Fix: Hide unnecessary component when pringing
+
+## v4.1.4 (Missing number)
+
+## v4.1.3
 
 * Feature: Create/edit linker with GUI
 * Improvement: Paging page histories
@@ -93,7 +105,7 @@ Upgrading Guide: <https://docs.growi.org/en/admin-guide/upgrading/41x.html>
 * Fix: Unable to create page with original path after emptying trash
 * I18n: Support zh-CN
 
-## v4.0.8  (Missing number)
+## v4.0.8 (Missing number)
 
 ## v4.0.7
 

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.1.3-RC",
+  "version": "4.1.6-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

+ 8 - 7
resource/locales/en_US/admin/admin.json

@@ -27,20 +27,21 @@
     "attach_enable": "You can attach files other than image files if you enable this option.",
     "update": "Update",
     "mail_settings": "E-mail Settings",
-    "smtp_used": "If you have SMTP settings, it will be used.",
-    "smtp_but_aws": "If you do not have SMTP settings but AWS settings,  e-mails will be sent by SES.",
-    "neihter_of": "If neither is selected, then no email will be sent.",
+    "mailer_is_not_set_up": "E-mail setting is not set up.",
     "from_e-mail_address": "From e-mail address",
+    "transmission_method":"Transmission Method",
+    "smtp_label":"SMTP",
+    "ses_label":"SES(AWS)",
+    "send_test_email": "Send a test-email",
+    "success_to_send_test_email": "Success to send a test-email",
     "smtp_settings": "SMTP settings",
     "host": "Host",
     "port": "Port",
     "user": "User",
-    "initialize_mail_settings": "initialize e-mail settings",
-    "initialize_mail_modal_header": "Initialize e-mail settings",
-    "confirm_to_initialize_mail_settings": "You can't restore to the current settings. Are you sure you want to initialize e-mail settings?",
+    "ses_settings":"SES settings",
+    "test_connection": "Test connection to mail",
     "aws_settings": "AWS settings",
     "aws_access": "This is for AWS settings. If you complete AWS settings, file upload function, profile picture function etc will be enabled.",
-    "no_smtp_setting": "If you do not have SMTP settings, e-mails will be sent via SES. You need to verify from e-mail address and production settings.",
     "change_setting": "Caution:if you change this setting not completed, you will not be able to access files you have uploaded so far.",
     "region": "Region",
     "bucket_name": "Bucket name",

+ 2 - 1
resource/locales/en_US/translation.json

@@ -754,6 +754,7 @@
   },
   "validation":{
     "aws_region": "For the region, enter the AWS region name. ex):us-east-1",
-    "aws_custom_endpoint":"For the custom endpoint, specify the URL that starts with http(s)://. Also, the trailing slash is not required."
+    "aws_custom_endpoint":"For the custom endpoint, specify the URL that starts with http(s)://. Also, the trailing slash is not required.",
+    "failed_to_send_a_test_email":"Failed to send a test email using SMTP. Please check your settings."
   }
 }

+ 8 - 7
resource/locales/ja_JP/admin/admin.json

@@ -27,20 +27,21 @@
     "attach_enable": "許可をしている場合、画像以外のファイルをページに添付可能になります。",
     "update": "更新",
     "mail_settings": "メールの設定",
-    "smtp_used": "SMTPの設定がされている場合、それが利用されます。",
-    "smtp_but_aws": "SMTP設定がなく、AWSの設定がある場合、SESでの送信を試みます。",
-    "neihter_of": "どちらの設定もない場合、メールは送信されません。",
+    "mailer_is_not_set_up": "メール設定がセットアップされていません。",
     "from_e-mail_address": "Fromアドレス",
+    "transmission_method":"送信方法",
+    "smtp_label":"SMTP",
+    "ses_label":"SES(AWS)",
+    "send_test_email": "テストメールを送信",
+    "success_to_send_test_email": "テストメールを送信しました。",
     "smtp_settings": "SMTP設定",
     "host": "ホスト",
     "port": "ポート",
     "user": "ユーザー",
-    "initialize_mail_settings": "設定を初期化",
-    "initialize_mail_modal_header": "メール設定の初期化",
-    "confirm_to_initialize_mail_settings": "一度初期化した設定は戻せません。本当に初期化しますか?",
+    "ses_settings":"SES設定",
+    "test_connection": "接続テスト",
     "aws_settings": "AWS設定",
     "aws_access": "AWS にアクセスするための設定を行います。AWS の設定を完了させると、ファイルアップロード機能、プロフィール写真機能などが有効になります。",
-    "no_smtp_setting": "また、SMTP の設定が無い場合、SES を利用したメール送信が行われます。FromメールアドレスのVerify、プロダクション利用設定をする必要があります。",
     "change_setting": "この設定を途中で変更すると、これまでにアップロードしたファイル等へのアクセスができなくなりますのでご注意下さい。",
     "region": "リージョン",
     "bucket_name": "バケット名",

+ 2 - 1
resource/locales/ja_JP/translation.json

@@ -747,6 +747,7 @@
   },
   "validation":{
     "aws_region": "リージョンには、AWSリージョン名を入力してください。例: ap-northeast-1",
-    "aws_custom_endpoint": "カスタムエンドポイントは、http(s)://で始まるURLを指定してください。また、末尾の/は不要です。"
+    "aws_custom_endpoint": "カスタムエンドポイントは、http(s)://で始まるURLを指定してください。また、末尾の/は不要です。",
+    "failed_to_send_a_test_email":"SMTPを利用したテストメール送信に失敗しました。設定をみなおしてください。"
   }
 }

+ 11 - 9
resource/locales/zh_CN/admin/admin.json

@@ -27,20 +27,22 @@
 		"attach_enable": "如果启用此选项,则可以附加图像文件以外的文件。",
 		"update": "更新",
 		"mail_settings": "邮件设置",
-		"smtp_used": "如果您有SMTP设置,将使用它。",
-		"smtp_but_aws": "如果您没有SMTP设置,但有AWS设置,则电子邮件将由SES发送。",
-		"neihter_of": "如果两者都未选中,则不会发送电子邮件。",
-		"from_e-mail_address": "From e-mail address",
-		"smtp_settings": "SMTP 设置",
+    "mailer_is_not_set_up": "邮件设置尚未完成。",
+    "transmission_method":"传送方法",
+    "smtp_label":"SMTP",
+    "ses_label":"SES(AWS)",
+		"from_e-mail_address": "邮件发出地址",
+    "send_test_email": "发送测试邮件",
+    "success_to_send_test_email": "成功发送了一封测试邮件",
+    "smtp_settings": "SMTP 设置",
 		"host": "服务器",
 		"port": "端口号",
 		"user": "用户名",
-    "initialize_mail_settings": "重置邮件设置",
-    "initialize_mail_modal_header": "重置邮件设置",
-    "confirm_to_initialize_mail_settings": "当前设置将被清空且不可恢复。确认重置?",
+    "ses_settings":"SES设置",
+    "test_connection": "测试邮件服务器连接",
 		"aws_settings": "AWS设置",
 		"aws_access": "这是用于AWS设置的。如果您完成了AWS设置,文件上传功能,个人资料图片功能等将被启用。",
-		"no_smtp_setting": "如果您没有SMTP设置,电子邮件将通过SES发送。您需要从电子邮件地址和生产设置进行验证。",
+		"": "如果您没有SMTP设置,电子邮件将通过SES发送。您需要从电子邮件地址和生产设置进行验证。",
 		"change_setting": "注意:如果你更改此设置未完成,您将无法访问迄今为止上传的文件。",
 		"region": "Region",
 		"bucket_name": "Bucket name",

+ 15 - 14
resource/locales/zh_CN/translation.json

@@ -51,15 +51,15 @@
   "attachment_data": "Attachment Data",
 	"Presentation Mode": "演示文稿",
   "Not available for guest": "Not available for guest",
-  "Create Archive Page": "Create Archive Page",
-  "File type": "File type",
-  "Target page": "Target page",
-  "Include Attachment File": "Include Attachment File",
-  "Include Comment": "Include Comment",
-  "Include Subordinated Page": "Include Subordinated Page",
-  "All Subordinated Page": "All Subordinated Page",
-  "Specify Hierarchy": "Specify Hierarchy",
-  "Submitted the request to create the archive": "Submitted the request to create the archive",
+  "Create Archive Page": "创建归档页",
+  "File type": "文件类型",
+  "Target page": "目标页面",
+  "Include Attachment File": "包含附件",
+  "Include Comment": "包含评论",
+  "Include Subordinated Page": "包括子页面",
+  "All Subordinated Page": "所有子页面",
+  "Specify Hierarchy": "指定层级",
+  "Submitted the request to create the archive": "提交创建归档请求",
   "username": "用户名",
 	"Created": "创建",
 	"Last updated": "上次更新",
@@ -727,10 +727,10 @@
 		"Setup": "安装程序"
 	},
   "export_bulk": {
-    "failed_to_export": "Failed to export",
-    "failed_to_count_pages": "Failed to count pages",
-    "export_page_markdown": "Export page as Markdown",
-    "export_page_pdf": "Export page as PDF"
+    "failed_to_export": "导出失败",
+    "failed_to_count_pages": "页面计数失败",
+    "export_page_markdown": "以Markdown格式导出页面",
+    "export_page_pdf": "以PDF格式导出页面"
   },
 	"message": {
 		"successfully_connected": "连接成功!",
@@ -758,6 +758,7 @@
   },
   "validation":{
     "aws_region": "关于地区,请输入AWS地区名,例如:ap-east-1",
-    "aws_custom_endpoint": "关于自定义端点,请指定以http(s)://开头的URL,链接末尾不需要添加“/”"
+    "aws_custom_endpoint": "关于自定义端点,请指定以http(s)://开头的URL,链接末尾不需要添加“/”",
+    "failed_to_send_a_test_email":"SMTP方式测试邮件发送失败,请检查相关设定。"
   }
 }

+ 1 - 1
src/client/js/app.jsx

@@ -94,7 +94,7 @@ if (pageContainer.state.pageId != null) {
     'seen-user-list': <SeenUserList />,
     'liker-list': <LikerList />,
 
-    'user-created-list': <RecentCreated />,
+    'user-created-list': <RecentCreated userId={pageContainer.state.creator._id} />,
     'user-draft-list': <MyDraftList />,
   });
 }

+ 0 - 3
src/client/js/components/Admin/App/AwsSetting.jsx

@@ -41,9 +41,6 @@ class AwsSetting extends React.Component {
         <p className="card well">
           {t('admin:app_setting.aws_access')}
           <br />
-          {t('admin:app_setting.no_smtp_setting')}
-          <br />
-          <br />
           <span className="text-danger">
             <i className="ti-unlink"></i>
             {t('admin:app_setting.change_setting')}

+ 70 - 169
src/client/js/components/Admin/App/MailSetting.jsx

@@ -1,204 +1,105 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
-import loggerFactory from '@alias/logger';
 
-import { Modal, ModalHeader, ModalBody } from 'reactstrap';
-import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '../../../util/apiNotification';
+import { withUnstatedContainers } from '../../UnstatedUtils';
 
 import AppContainer from '../../../services/AppContainer';
 import AdminAppContainer from '../../../services/AdminAppContainer';
+import SmtpSetting from './SmtpSetting';
+import SesSetting from './SesSetting';
 
-const logger = loggerFactory('growi:appSettings');
-
-class MailSetting extends React.Component {
 
-  constructor(props) {
-    super(props);
+function MailSetting(props) {
+  const { t, adminAppContainer } = props;
 
-    this.state = {
-      isInitializeValueModalOpen: false,
-    };
+  const transmissionMethods = ['smtp', 'ses'];
 
-    this.emailInput = React.createRef();
-    this.hostInput = React.createRef();
-    this.portInput = React.createRef();
-    this.userInput = React.createRef();
-    this.passwordInput = React.createRef();
-
-    this.openInitializeValueModal = this.openInitializeValueModal.bind(this);
-    this.closeInitializeValueModal = this.closeInitializeValueModal.bind(this);
-    this.submitFromAdressHandler = this.submitFromAdressHandler.bind(this);
-    this.submitHandler = this.submitHandler.bind(this);
-    this.initialize = this.initialize.bind(this);
-  }
-
-  openInitializeValueModal() {
-    this.setState({ isInitializeValueModalOpen: true });
-  }
-
-  closeInitializeValueModal() {
-    this.setState({ isInitializeValueModalOpen: false });
-  }
-
-  async submitHandler() {
-    const { t, adminAppContainer } = this.props;
+  async function submitHandler() {
+    const { t } = props;
 
     try {
       await adminAppContainer.updateMailSettingHandler();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.mail_settings') }));
+      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.ses_settings') }));
     }
     catch (err) {
       toastError(err);
-      logger.error(err);
     }
   }
 
-  async submitFromAdressHandler() {
-    const { t, adminAppContainer } = this.props;
-
+  async function sendTestEmailHandler() {
+    const { adminAppContainer } = props;
     try {
-      await adminAppContainer.updateFromAdressHandler();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.mail_settings') }));
+      await adminAppContainer.sendTestEmail();
+      toastSuccess(t('admin:app_setting.success_to_send_test_email'));
     }
     catch (err) {
       toastError(err);
-      logger.error(err);
     }
   }
 
-  async initialize() {
-    const { t, adminAppContainer } = this.props;
 
-    try {
-      const mailSettingParams = await adminAppContainer.initializeMailSettingHandler();
-      toastSuccess(t('toaster.initialize_successed', { target: t('admin:app_setting.smtp_settings') }));
-      // convert values to '' if value is null for overwriting values of inputs with refs
-      this.hostInput.current.value = mailSettingParams.smtpHost || '';
-      this.portInput.current.value = mailSettingParams.smtpPort || '';
-      this.userInput.current.value = mailSettingParams.smtpUser || '';
-      this.passwordInput.current.value = mailSettingParams.smtpPassword || '';
-      this.closeInitializeValueModal();
-    }
-    catch (err) {
-      toastError(err);
-      logger.error(err);
-    }
-  }
-
-  render() {
-    const { t, adminAppContainer } = this.props;
-
-    return (
-      <React.Fragment>
-        <p className="card well">{t('admin:app_setting.smtp_used')} {t('admin:app_setting.smtp_but_aws')}<br />{t('admin:app_setting.neihter_of')}</p>
-        <div className="row form-group mb-5">
-          <label className="col-md-3 col-form-label text-left">{t('admin:app_setting.from_e-mail_address')}</label>
-          <div className="col-md-6">
-            <input
-              className="form-control"
-              type="text"
-              ref={this.emailInput}
-              placeholder={`${t('eg')} mail@growi.org`}
-              defaultValue={adminAppContainer.state.fromAddress || ''}
-              onChange={(e) => { adminAppContainer.changeFromAddress(e.target.value) }}
-            />
-          </div>
+  return (
+    <React.Fragment>
+      {!adminAppContainer.state.isMailerSetup && (
+        <div className="alert alert-danger"><i className="icon-exclamation"></i> {t('admin:app_setting.mailer_is_not_set_up')}</div>
+      )}
+      <div className="row form-group mb-5">
+        <label className="col-md-3 col-form-label text-right">{t('admin:app_setting.from_e-mail_address')}</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            placeholder={`${t('eg')} mail@growi.org`}
+            defaultValue={adminAppContainer.state.fromAddress || ''}
+            onChange={(e) => { adminAppContainer.changeFromAddress(e.target.value) }}
+          />
         </div>
-        <div className="row my-3">
-          <div className="mx-auto">
-            <button type="button" className="btn btn-primary" onClick={this.submitFromAdressHandler}>{ t('Update') }</button>
-          </div>
+      </div>
+
+      <div className="row form-group mb-5">
+        <label className="text-left text-md-right col-md-3 col-form-label">
+          {t('admin:app_setting.transmission_method')}
+        </label>
+        <div className="col-md-6">
+          {transmissionMethods.map((method) => {
+              return (
+                <div key={method} className="custom-control custom-radio custom-control-inline">
+                  <input
+                    type="radio"
+                    className="custom-control-input"
+                    name="transmission-method"
+                    id={`transmission-nethod-radio-${method}`}
+                    checked={adminAppContainer.state.transmissionMethod === method}
+                    onChange={(e) => {
+                    adminAppContainer.changeTransmissionMethod(method);
+                  }}
+                  />
+                  <label className="custom-control-label" htmlFor={`transmission-nethod-radio-${method}`}>{t(`admin:app_setting.${method}_label`)}</label>
+                </div>
+              );
+            })}
         </div>
-        <div id="mail-smtp" className="tab-pane active mt-5">
-          <div className="row form-group mb-5">
-            <label className="col-md-3 col-form-label text-left">{t('admin:app_setting.smtp_settings')}</label>
-            <div className="col-md-4">
-              <label>{t('admin:app_setting.host')}</label>
-              <input
-                className="form-control"
-                type="text"
-                ref={this.hostInput}
-                defaultValue={adminAppContainer.state.smtpHost || ''}
-                onChange={(e) => { adminAppContainer.changeSmtpHost(e.target.value) }}
-              />
-            </div>
-            <div className="col-md-2">
-              <label>{t('admin:app_setting.port')}</label>
-              <input
-                className="form-control"
-                ref={this.portInput}
-                defaultValue={adminAppContainer.state.smtpPort || ''}
-                onChange={(e) => { adminAppContainer.changeSmtpPort(e.target.value) }}
-              />
-            </div>
-          </div>
-
-          <div className="row form-group mb-5">
-            <div className="col-md-3 offset-md-3">
-              <label>{t('admin:app_setting.user')}</label>
-              <input
-                className="form-control"
-                type="text"
-                ref={this.userInput}
-                defaultValue={adminAppContainer.state.smtpUser || ''}
-                onChange={(e) => { adminAppContainer.changeSmtpUser(e.target.value) }}
-              />
-            </div>
-            <div className="col-md-3">
-              <label>{t('Password')}</label>
-              <input
-                className="form-control"
-                type="password"
-                ref={this.passwordInput}
-                defaultValue={adminAppContainer.state.smtpPassword || ''}
-                onChange={(e) => { adminAppContainer.changeSmtpPassword(e.target.value) }}
-              />
-            </div>
-          </div>
-
-          <div className="row my-3">
-            <div className="offset-5">
-              <button type="button" className="btn btn-primary" onClick={this.submitHandler} disabled={adminAppContainer.state.retrieveError != null}>
-                { t('Update') }
-              </button>
-            </div>
-            <div className="offset-1">
-              <button
-                type="button"
-                className="btn btn-secondary"
-                onClick={this.openInitializeValueModal}
-                disabled={adminAppContainer.state.retrieveError != null}
-              >
-                {t('admin:app_setting.initialize_mail_settings')}
-              </button>
-            </div>
-          </div>
+      </div>
+
+      {adminAppContainer.state.transmissionMethod === 'smtp' && <SmtpSetting />}
+      {adminAppContainer.state.transmissionMethod === 'ses' && <SesSetting />}
+
+      <div className="row my-3">
+        <div className="mx-auto">
+          <button type="button" className="btn btn-primary" onClick={submitHandler} disabled={adminAppContainer.state.retrieveError != null}>
+            { t('Update') }
+          </button>
+          {adminAppContainer.state.transmissionMethod === 'smtp' && (
+          <button type="button" className="btn btn-secondary ml-4" onClick={sendTestEmailHandler}>
+            {t('admin:app_setting.send_test_email')}
+          </button>
+          )}
         </div>
-
-
-        <Modal isOpen={this.state.isInitializeValueModalOpen} toggle={this.closeInitializeValueModal} className="initialize-mail-settings">
-          <ModalHeader tag="h4" toggle={this.closeInitializeValueModal} className="bg-danger text-light">
-            {t('admin:app_setting.initialize_mail_modal_header')}
-          </ModalHeader>
-          <ModalBody>
-            <div className="text-center mb-4">
-              {t('admin:app_setting.confirm_to_initialize_mail_settings')}
-            </div>
-            <div className="text-center my-2">
-              <button type="button" className="btn btn-outline-secondary mr-4" onClick={this.closeInitializeValueModal}>
-                {t('Cancel')}
-              </button>
-              <button type="button" className="btn btn-danger" onClick={this.initialize}>
-                {t('Reset')}
-              </button>
-            </div>
-          </ModalBody>
-        </Modal>
-      </React.Fragment>
-    );
-  }
+      </div>
+    </React.Fragment>
+  );
 
 }
 

+ 68 - 0
src/client/js/components/Admin/App/SesSetting.jsx

@@ -0,0 +1,68 @@
+
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import { withLoadingSppiner } from '../../SuspenseUtils';
+
+
+import AppContainer from '../../../services/AppContainer';
+import AdminAppContainer from '../../../services/AdminAppContainer';
+
+function SmtpSetting(props) {
+  const { adminAppContainer } = props;
+
+  return (
+    <React.Fragment>
+      <div id="mail-smtp" className="tab-pane active mt-5">
+
+        <div className="row form-group">
+          <label className="text-left text-md-right col-md-3 col-form-label">
+            Access key ID
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              type="text"
+              defaultValue={adminAppContainer.state.sesAccessKeyId || ''}
+              onChange={(e) => {
+                adminAppContainer.changeSesAccessKeyId(e.target.value);
+              }}
+            />
+          </div>
+        </div>
+
+        <div className="row form-group">
+          <label className="text-left text-md-right col-md-3 col-form-label">
+            Secret access key
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              type="text"
+              defaultValue={adminAppContainer.state.sesSecretAccessKey || ''}
+              onChange={(e) => {
+                adminAppContainer.changeSesSecretAccessKey(e.target.value);
+              }}
+            />
+          </div>
+        </div>
+      </div>
+
+    </React.Fragment>
+  );
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const SmtpSettingWrapper = withUnstatedContainers(withLoadingSppiner(SmtpSetting), [AppContainer, AdminAppContainer]);
+
+SmtpSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
+};
+
+export default withTranslation()(SmtpSettingWrapper);

+ 89 - 0
src/client/js/components/Admin/App/SmtpSetting.jsx

@@ -0,0 +1,89 @@
+
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import { withLoadingSppiner } from '../../SuspenseUtils';
+
+
+import AppContainer from '../../../services/AppContainer';
+import AdminAppContainer from '../../../services/AdminAppContainer';
+
+function SmtpSetting(props) {
+  const { adminAppContainer, t } = props;
+
+  return (
+    <React.Fragment>
+      <div id="mail-smtp" className="tab-pane active mt-5">
+        <div className="row form-group">
+          <label className="text-left text-md-right col-md-3 col-form-label">
+            {t('admin:app_setting.host')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              type="text"
+              defaultValue={adminAppContainer.state.smtpHost || ''}
+              onChange={(e) => { adminAppContainer.changeSmtpHost(e.target.value) }}
+            />
+          </div>
+        </div>
+
+        <div className="row form-group">
+          <label className="text-left text-md-right col-md-3 col-form-label">
+            {t('admin:app_setting.port')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              defaultValue={adminAppContainer.state.smtpPort || ''}
+              onChange={(e) => { adminAppContainer.changeSmtpPort(e.target.value) }}
+            />
+          </div>
+        </div>
+
+        <div className="row form-group">
+          <label className="text-left text-md-right col-md-3 col-form-label">
+            {t('admin:app_setting.user')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              type="text"
+              defaultValue={adminAppContainer.state.smtpUser || ''}
+              onChange={(e) => { adminAppContainer.changeSmtpUser(e.target.value) }}
+            />
+          </div>
+        </div>
+
+        <div className="row form-group">
+          <label className="text-left text-md-right col-md-3 col-form-label">
+            {t('Password')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              type="password"
+              defaultValue={adminAppContainer.state.smtpPassword || ''}
+              onChange={(e) => { adminAppContainer.changeSmtpPassword(e.target.value) }}
+            />
+          </div>
+        </div>
+      </div>
+    </React.Fragment>
+  );
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const SmtpSettingWrapper = withUnstatedContainers(withLoadingSppiner(SmtpSetting), [AppContainer, AdminAppContainer]);
+
+SmtpSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
+};
+
+export default withTranslation()(SmtpSettingWrapper);

+ 22 - 0
src/client/js/components/Icons/PresentationIcon.jsx

@@ -0,0 +1,22 @@
+import React from 'react';
+
+const PresentationIcon = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    width="12.25"
+    height="14"
+    viewBox="0 0 12.25 14"
+  >
+    <path
+      d="M44.261,0H32.909a.448.448,0,0,0-.449.448V7.635a.449.449,0,0,0,.9,0V.9H43.812V7.635a.449.449,0,0,0,.9,0V.448A.448.448,0,0,0,44.261,0Z"
+      transform="translate(-32.46)"
+    />
+    <path
+      d="M90.959,287.182H82.315a.448.448,0,1,0,0,.9h3.873v1.115l-3.207,3.381a.449.449,0,0,0,.652.616l2.555-2.694v2.013a.449.449,0,0,0,.9,0V
+        290.5l2.555,2.694a.449.449,0,0,0,.652-.616l-3.208-3.382v-1.114h3.873a.448.448,0,1,0,0-.9Z"
+      transform="translate(-80.512 -279.329)"
+    />
+  </svg>
+);
+
+export default PresentationIcon;

+ 2 - 1
src/client/js/components/Page/PageManagement.jsx

@@ -13,6 +13,7 @@ import PageRenameModal from '../PageRenameModal';
 import PageDuplicateModal from '../PageDuplicateModal';
 import CreateTemplateModal from '../CreateTemplateModal';
 import PagePresentationModal from '../PagePresentationModal';
+import PresentationIcon from '../Icons/PresentationIcon';
 
 
 const PageManagement = (props) => {
@@ -109,7 +110,7 @@ const PageManagement = (props) => {
           <i className="icon-fw icon-docs"></i> { t('Duplicate') }
         </button>
         <button className="dropdown-item" type="button" onClick={openPagePresentationModalHandler}>
-          <i className="icon-film icon-fw"></i><span className="d-none d-sm-inline">{ t('Presentation Mode') }</span>
+          <i className="icon-fw"><PresentationIcon /></i><span className="d-none d-sm-inline"> { t('Presentation Mode') }</span>
         </button>
         <button type="button" className="dropdown-item" onClick={() => { exportPageHandler('md') }}>
           <i className="icon-fw icon-cloud-download"></i>{t('export_bulk.export_page_markdown')}

+ 15 - 21
src/client/js/components/RecentCreated/RecentCreated.jsx

@@ -3,7 +3,6 @@ import PropTypes from 'prop-types';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '../../services/AppContainer';
-import PageContainer from '../../services/PageContainer';
 
 import PaginationWrapper from '../PaginationWrapper';
 
@@ -33,29 +32,23 @@ class RecentCreated extends React.Component {
     await this.getRecentCreatedList(selectedPage);
   }
 
-  getRecentCreatedList(selectPageNumber) {
-    const { appContainer, pageContainer } = this.props;
-    const { pageId } = pageContainer.state;
+  async getRecentCreatedList(selectPageNumber) {
+    const { appContainer, userId } = this.props;
 
-    const userId = appContainer.currentUserId;
     const limit = appContainer.getConfig().recentCreatedLimit;
     const offset = (selectPageNumber - 1) * limit;
 
     // pagesList get and pagination calculate
-    this.props.appContainer.apiGet('/pages.recentCreated', {
-      page_id: pageId, user: userId, limit, offset,
-    })
-      .then((res) => {
-        const totalPages = res.totalCount;
-        const pages = res.pages;
-        const activePage = selectPageNumber;
-        this.setState({
-          pages,
-          activePage,
-          totalPages,
-          pagingLimit: limit,
-        });
-      });
+    const res = await appContainer.apiv3Get(`/users/${userId}/recent`, { offset, limit });
+    const { totalCount, pages } = res.data;
+
+    this.setState({
+      pages,
+      activePage: selectPageNumber,
+      totalPages: totalCount,
+      pagingLimit: limit,
+    });
+
   }
 
   /**
@@ -95,11 +88,12 @@ class RecentCreated extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const RecentCreatedWrapper = withUnstatedContainers(RecentCreated, [AppContainer, PageContainer]);
+const RecentCreatedWrapper = withUnstatedContainers(RecentCreated, [AppContainer]);
 
 RecentCreated.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
+  userId: PropTypes.string.isRequired,
 };
 
 export default RecentCreatedWrapper;

+ 1 - 4
src/client/js/components/ShareLink/ShareLink.jsx

@@ -86,7 +86,7 @@ class ShareLink extends React.Component {
 
     return (
       <div className="container">
-        <h3 className="grw-modal-head  d-flex  pb-2">
+        <h3 className="grw-modal-head d-flex pb-2">
           { t('share_links.share_link_list') }
           <button className="btn btn-danger ml-auto " type="button" onClick={this.deleteAllLinksButtonHandler}>{t('delete_all')}</button>
         </h3>
@@ -120,9 +120,6 @@ ShareLink.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func.isRequired,
 };
 
 export default withTranslation()(ShareLinkWrapper);

+ 58 - 38
src/client/js/services/AdminAppContainer.js

@@ -23,11 +23,15 @@ export default class AdminAppContainer extends Container {
       siteUrl: '',
       envSiteUrl: '',
       isSetSiteUrl: true,
+      isMailerSetup: false,
       fromAddress: '',
+      transmissionMethod: '',
       smtpHost: '',
       smtpPort: '',
       smtpUser: '',
       smtpPassword: '',
+      sesAccessKeyId: '',
+      sesSecretAccessKey: '',
       region: '',
       customEndpoint: '',
       bucket: '',
@@ -36,27 +40,6 @@ export default class AdminAppContainer extends Container {
       isEnabledPlugins: true,
     };
 
-    this.changeTitle = this.changeTitle.bind(this);
-    this.changeConfidential = this.changeConfidential.bind(this);
-    this.changeGlobalLang = this.changeGlobalLang.bind(this);
-    this.changeFileUpload = this.changeFileUpload.bind(this);
-    this.changeSiteUrl = this.changeSiteUrl.bind(this);
-    this.changeFromAddress = this.changeFromAddress.bind(this);
-    this.changeSmtpHost = this.changeSmtpHost.bind(this);
-    this.changeSmtpPort = this.changeSmtpPort.bind(this);
-    this.changeSmtpUser = this.changeSmtpUser.bind(this);
-    this.changeSmtpPassword = this.changeSmtpPassword.bind(this);
-    this.changeRegion = this.changeRegion.bind(this);
-    this.changeCustomEndpoint = this.changeCustomEndpoint.bind(this);
-    this.changeBucket = this.changeBucket.bind(this);
-    this.changeAccessKeyId = this.changeAccessKeyId.bind(this);
-    this.changeSecretAccessKey = this.changeSecretAccessKey.bind(this);
-    this.changeIsEnabledPlugins = this.changeIsEnabledPlugins.bind(this);
-    this.updateAppSettingHandler = this.updateAppSettingHandler.bind(this);
-    this.updateSiteUrlSettingHandler = this.updateSiteUrlSettingHandler.bind(this);
-    this.updateMailSettingHandler = this.updateMailSettingHandler.bind(this);
-    this.updateAwsSettingHandler = this.updateAwsSettingHandler.bind(this);
-    this.updatePluginSettingHandler = this.updatePluginSettingHandler.bind(this);
   }
 
   /**
@@ -81,11 +64,15 @@ export default class AdminAppContainer extends Container {
       siteUrl: appSettingsParams.siteUrl,
       envSiteUrl: appSettingsParams.envSiteUrl,
       isSetSiteUrl: !!appSettingsParams.siteUrl,
+      isMailerSetup: appSettingsParams.isMailerSetup,
       fromAddress: appSettingsParams.fromAddress,
+      transmissionMethod: appSettingsParams.transmissionMethod,
       smtpHost: appSettingsParams.smtpHost,
       smtpPort: appSettingsParams.smtpPort,
       smtpUser: appSettingsParams.smtpUser,
       smtpPassword: appSettingsParams.smtpPassword,
+      sesAccessKeyId: appSettingsParams.sesAccessKeyId,
+      sesSecretAccessKey: appSettingsParams.sesSecretAccessKey,
       region: appSettingsParams.region,
       customEndpoint: appSettingsParams.customEndpoint,
       bucket: appSettingsParams.bucket,
@@ -138,6 +125,13 @@ export default class AdminAppContainer extends Container {
     this.setState({ fromAddress });
   }
 
+  /**
+   * Change from transmission method
+   */
+  changeTransmissionMethod(transmissionMethod) {
+    this.setState({ transmissionMethod });
+  }
+
   /**
    * Change smtp host
    */
@@ -166,6 +160,20 @@ export default class AdminAppContainer extends Container {
     this.setState({ smtpPassword });
   }
 
+  /**
+   * Change sesAccessKeyId
+   */
+  changeSesAccessKeyId(sesAccessKeyId) {
+    this.setState({ sesAccessKeyId });
+  }
+
+  /**
+   * Change sesSecretAccessKey
+   */
+  changeSesSecretAccessKey(sesSecretAccessKey) {
+    this.setState({ sesSecretAccessKey });
+  }
+
   /**
    * Change region
    */
@@ -239,49 +247,61 @@ export default class AdminAppContainer extends Container {
   }
 
   /**
-   * Update from adress
+   * Update mail setting
    * @memberOf AdminAppContainer
    * @return {Array} Appearance
    */
-  async updateFromAdressHandler() {
-    const response = await this.appContainer.apiv3.put('/app-settings/from-address', {
-      fromAddress: this.state.fromAddress,
-    });
-    const { mailSettingParams } = response.data;
-    return mailSettingParams;
+  updateMailSettingHandler() {
+    if (this.state.transmissionMethod === 'smtp') {
+      return this.updateSmtpSetting();
+    }
+    return this.updateSesSetting();
   }
 
   /**
-   * Update mail setting
+   * Update smtp setting
    * @memberOf AdminAppContainer
    * @return {Array} Appearance
    */
-  async updateMailSettingHandler() {
-    const response = await this.appContainer.apiv3.put('/app-settings/mail-setting', {
+  async updateSmtpSetting() {
+    const response = await this.appContainer.apiv3.put('/app-settings/smtp-setting', {
       fromAddress: this.state.fromAddress,
+      transmissionMethod: this.state.transmissionMethod,
       smtpHost: this.state.smtpHost,
       smtpPort: this.state.smtpPort,
       smtpUser: this.state.smtpUser,
       smtpPassword: this.state.smtpPassword,
     });
     const { mailSettingParams } = response.data;
+    this.setState({ isMailerSetup: mailSettingParams.isMailerSetup });
     return mailSettingParams;
   }
 
   /**
-   * Initialize mail setting
+   * Update ses setting
    * @memberOf AdminAppContainer
    * @return {Array} Appearance
    */
-  async initializeMailSettingHandler() {
-    const response = await this.appContainer.apiv3.delete('/app-settings/mail-setting', {});
-    const {
-      mailSettingParams,
-    } = response.data;
-    this.setState(mailSettingParams);
+  async updateSesSetting() {
+    const response = await this.appContainer.apiv3.put('/app-settings/ses-setting', {
+      fromAddress: this.state.fromAddress,
+      transmissionMethod: this.state.transmissionMethod,
+      sesAccessKeyId: this.state.sesAccessKeyId,
+      sesSecretAccessKey: this.state.sesSecretAccessKey,
+    });
+    const { mailSettingParams } = response.data;
+    this.setState({ isMailerSetup: mailSettingParams.isMailerSetup });
     return mailSettingParams;
   }
 
+  /**
+   * send test e-mail
+   * @memberOf AdminAppContainer
+   */
+  async sendTestEmail() {
+    return this.appContainer.apiv3.post('/app-settings/smtp-test');
+  }
+
   /**
    * Update AWS setting
    * @memberOf AdminAppContainer

+ 68 - 0
src/migrations/20200828024025-copy-aws-setting.js

@@ -0,0 +1,68 @@
+require('module-alias/register');
+const logger = require('@alias/logger')('growi:migrate:remove-layout-setting');
+
+const mongoose = require('mongoose');
+const config = require('@root/config/migrate');
+
+const { getModelSafely } = require('@commons/util/mongoose-utils');
+
+module.exports = {
+  async up(db, client) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const Config = getModelSafely('Config') || require('@server/models/config')();
+
+    const [accessKeyId, secretAccessKey] = await Promise.all([
+      Config.findOne({ key: 'aws:accessKeyId' }),
+      Config.findOne({ key: 'aws:secretAccessKey' }),
+    ]);
+
+    const request = [];
+
+    if (accessKeyId != null) {
+      if (accessKeyId.value != null) {
+        request.push({
+          insertOne: {
+            document: {
+              key: 'mail:sesAccessKeyId',
+              ns: 'crowi',
+              value: accessKeyId.value,
+            },
+          },
+        });
+      }
+    }
+
+    if (secretAccessKey != null) {
+      if (secretAccessKey.value != null) {
+        request.push({
+          insertOne: {
+            document: {
+              key: 'mail:sesSecretAccessKey',
+              ns: 'crowi',
+              value: secretAccessKey.value,
+            },
+          },
+        });
+      }
+    }
+
+    if (request.length > 0) {
+      await Config.bulkWrite(request);
+    }
+
+    logger.info('Migration has successfully applied');
+  },
+
+  async down(db, client) {
+    logger.info('Rollback migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const Config = getModelSafely('Config') || require('@server/models/config')();
+
+    await Config.deleteMany({ key: { $in: ['mail:sesAccessKeyId', 'mail:sesSecretAccessKey'] } });
+
+    logger.info('Migration has been successfully rollbacked');
+  },
+};

+ 50 - 0
src/migrations/20200901034313-update-mail-transmission.js

@@ -0,0 +1,50 @@
+require('module-alias/register');
+const logger = require('@alias/logger')('growi:migrate:update-mail-transmission');
+
+const mongoose = require('mongoose');
+const config = require('@root/config/migrate');
+
+const { getModelSafely } = require('@commons/util/mongoose-utils');
+
+module.exports = {
+  async up(db, client) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const Config = getModelSafely('Config') || require('@server/models/config')();
+
+    const sesExist = await Config.findOne({
+      ns: 'crowi',
+      key: 'mail:sesAccessKeyId',
+    });
+
+    if (sesExist == null) {
+      return logger.info('Document does not exist, value of transmission method will be set smtp automatically.');
+    }
+    const value = (
+      sesExist.value != null ? 'ses' : 'smtp'
+    );
+    await Config.create({
+      ns: 'crowi',
+      key: 'mail:transmissionMethod',
+      value,
+    });
+    logger.info('Migration has successfully applied');
+
+  },
+
+  async down(db, client) {
+    logger.info('Rollback migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const Config = getModelSafely('Config') || require('@server/models/config')();
+
+    // remote 'mail:transmissionMethod'
+    await Config.findOneAndDelete({
+      ns: 'crowi',
+      key: 'mail:transmissionMethod',
+    });
+
+    logger.info('Migration has been successfully rollbacked');
+  },
+};

+ 34 - 0
src/migrations/20200901034314-update-mail-transmission-fix.js

@@ -0,0 +1,34 @@
+require('module-alias/register');
+const logger = require('@alias/logger')('growi:migrate:update-mail-transmission-fix');
+
+const mongoose = require('mongoose');
+const config = require('@root/config/migrate');
+
+const { getModelSafely } = require('@commons/util/mongoose-utils');
+
+module.exports = {
+  async up(db, client) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const Config = getModelSafely('Config') || require('@server/models/config')();
+
+    const transmissionMethod = await Config.findOne({
+      ns: 'crowi',
+      key: 'mail:transmissionMethod',
+    });
+
+    if (transmissionMethod == null) {
+      return logger.info('No need to change.');
+    }
+
+    transmissionMethod.value = JSON.stringify(transmissionMethod.value);
+    await transmissionMethod.save();
+
+    logger.info('Migration has successfully applied');
+  },
+
+  async down(db, client) {
+    // do not rollback
+  },
+};

+ 108 - 87
src/server/routes/apiv3/app-settings.js

@@ -50,15 +50,18 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *          envSiteUrl:
  *            type: string
  *            description: environment variable 'APP_SITE_URL'
- *      FromAddress:
+ *      MailSetting:
  *        description: MailSettingParams
  *        type: object
  *        properties:
  *          fromAddress:
  *            type: string
  *            description: e-mail address used as from address of mail which sent from GROWI app
- *      MailSettingParams:
- *        description: MailSettingParams
+ *          transmissionMethod:
+ *            type: string
+ *            description: transmission method
+ *      SmtpSettingParams:
+ *        description: SmtpSettingParams
  *        type: object
  *        properties:
  *          smtpHost:
@@ -73,6 +76,16 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *          smtpPassword:
  *            type: string
  *            description: password of client's smtp server
+ *      SesSettingParams:
+ *        description: SesSettingParams
+ *        type: object
+ *        properties:
+ *          accessKeyId:
+ *            type: string
+ *            description: accesskey id for authentification of AWS
+ *          secretAccessKey:
+ *            type: string
+ *            description: secret key for authentification of AWS
  *      AwsSettingParams:
  *        description: AwsSettingParams
  *        type: object
@@ -119,20 +132,25 @@ module.exports = (crowi) => {
     siteUrlSetting: [
       body('siteUrl').trim().matches(/^(https?:\/\/[^/]+|)$/).isURL({ require_tld: false }),
     ],
-    fromAddress: [
+    mailSetting: [
       body('fromAddress').trim().if(value => value !== '').isEmail(),
+      body('transmissionMethod').isIn(['smtp', 'ses']),
     ],
-    mailSetting: [
+    smtpSetting: [
       body('smtpHost').trim(),
-      body('smtpPort').trim().isPort(),
+      body('smtpPort').trim().if(value => value !== '').isPort(),
       body('smtpUser').trim(),
       body('smtpPassword').trim(),
     ],
+    sesSetting: [
+      body('sesAccessKeyId').trim().if(value => value !== '').matches(/^[\da-zA-Z]+$/),
+      body('sesSecretAccessKey').trim(),
+    ],
     awsSetting: [
       body('region').trim().matches(/^[a-z]+-[a-z]+-\d+$/).withMessage((value, { req }) => req.t('validation.aws_region')),
       body('customEndpoint').trim().matches(/^(https?:\/\/[^/]+|)$/).withMessage((value, { req }) => req.t('validation.aws_custom_endpoint')),
       body('bucket').trim(),
-      body('accessKeyId').trim().matches(/^[\da-zA-Z]+$/),
+      body('accessKeyId').trim().if(value => value !== '').matches(/^[\da-zA-Z]+$/),
       body('secretAccessKey').trim(),
     ],
     pluginSetting: [
@@ -168,11 +186,15 @@ module.exports = (crowi) => {
       fileUpload: crowi.configManager.getConfig('crowi', 'app:fileUpload'),
       siteUrl: crowi.configManager.getConfig('crowi', 'app:siteUrl'),
       envSiteUrl: crowi.configManager.getConfigFromEnvVars('crowi', 'app:siteUrl'),
+      isMailerSetup: crowi.mailService.isMailerSetup,
       fromAddress: crowi.configManager.getConfig('crowi', 'mail:from'),
+      transmissionMethod: crowi.configManager.getConfig('crowi', 'mail:transmissionMethod'),
       smtpHost: crowi.configManager.getConfig('crowi', 'mail:smtpHost'),
       smtpPort: crowi.configManager.getConfig('crowi', 'mail:smtpPort'),
       smtpUser: crowi.configManager.getConfig('crowi', 'mail:smtpUser'),
       smtpPassword: crowi.configManager.getConfig('crowi', 'mail:smtpPassword'),
+      sesAccessKeyId: crowi.configManager.getConfig('crowi', 'mail:sesAccessKeyId'),
+      sesSecretAccessKey: crowi.configManager.getConfig('crowi', 'mail:sesSecretAccessKey'),
       region: crowi.configManager.getConfig('crowi', 'aws:region'),
       customEndpoint: crowi.configManager.getConfig('crowi', 'aws:customEndpoint'),
       bucket: crowi.configManager.getConfig('crowi', 'aws:bucket'),
@@ -297,21 +319,32 @@ module.exports = (crowi) => {
   /**
    * validate mail setting send test mail
    */
-  async function validateMailSetting(req) {
+  async function sendTestEmail(destinationAddress) {
+
     const { configManager, mailService } = crowi;
+
+    if (!mailService.isMailerSetup) {
+      throw Error('mailService is not setup');
+    }
+
     const fromAddress = configManager.getConfig('crowi', 'mail:from');
     if (fromAddress == null) {
       throw Error('fromAddress is not setup');
     }
 
+    const smtpHost = configManager.getConfig('crowi', 'mail:smtpHost');
+    const smtpPort = configManager.getConfig('crowi', 'mail:smtpPort');
+    const smtpUser = configManager.getConfig('crowi', 'mail:smtpUser');
+    const smtpPassword = configManager.getConfig('crowi', 'mail:smtpPassword');
+
     const option = {
-      host: req.body.smtpHost,
-      port: req.body.smtpPort,
+      host: smtpHost,
+      port: smtpPort,
     };
-    if (req.body.smtpUser && req.body.smtpPassword) {
+    if (smtpUser && smtpPassword) {
       option.auth = {
-        user: req.body.smtpUser,
-        pass: req.body.smtpPassword,
+        user: smtpUser,
+        pass: smtpPassword,
       };
     }
     if (option.port === 465) {
@@ -323,7 +356,7 @@ module.exports = (crowi) => {
 
     const mailOptions = {
       from: fromAddress,
-      to: req.user.email,
+      to: destinationAddress,
       subject: 'Wiki管理設定のアップデートによるメール通知',
       text: 'このメールは、WikiのSMTP設定のアップデートにより送信されています。',
     };
@@ -344,143 +377,134 @@ module.exports = (crowi) => {
     mailService.publishUpdatedMessage();
 
     return {
+      isMailerSetup: mailService.isMailerSetup,
+      fromAddress: configManager.getConfig('crowi', 'mail:from'),
       smtpHost: configManager.getConfig('crowi', 'mail:smtpHost'),
       smtpPort: configManager.getConfig('crowi', 'mail:smtpPort'),
       smtpUser: configManager.getConfig('crowi', 'mail:smtpUser'),
       smtpPassword: configManager.getConfig('crowi', 'mail:smtpPassword'),
+      sesAccessKeyId: configManager.getConfig('crowi', 'mail:sesAccessKeyId'),
+      sesSecretAccessKey: configManager.getConfig('crowi', 'mail:sesSecretAccessKey'),
     };
   };
 
   /**
    * @swagger
    *
-   *    /app-settings/from-address:
+   *    /app-settings/smtp-setting:
    *      put:
    *        tags: [AppSettings]
-   *        operationId: updateAppSettingFromAddress
-   *        summary: /app-settings/from-address
-   *        description: Update from address
+   *        operationId: updateAppSettingSmtpSetting
+   *        summary: /app-settings/smtp-setting
+   *        description: Update smtp setting
    *        requestBody:
    *          required: true
    *          content:
    *            application/json:
    *              schema:
-   *                $ref: '#/components/schemas/FromAddress'
+   *                $ref: '#/components/schemas/SmtpSettingParams'
    *        responses:
    *          200:
-   *            description: Succeeded to update from adress
+   *            description: Succeeded to update smtp setting
    *            content:
    *              application/json:
    *                schema:
-   *                  $ref: '#/components/schemas/FromAddress'
+   *                  $ref: '#/components/schemas/SmtpSettingParams'
    */
-  router.put('/from-address', loginRequiredStrictly, adminRequired, csrf, validator.fromAddress, apiV3FormValidator, async(req, res) => {
+  router.put('/smtp-setting', loginRequiredStrictly, adminRequired, csrf, validator.smtpSetting, apiV3FormValidator, async(req, res) => {
+    const requestMailSettingParams = {
+      'mail:from': req.body.fromAddress,
+      'mail:transmissionMethod': req.body.transmissionMethod,
+      'mail:smtpHost': req.body.smtpHost,
+      'mail:smtpPort': req.body.smtpPort,
+      'mail:smtpUser': req.body.smtpUser,
+      'mail:smtpPassword': req.body.smtpPassword,
+    };
 
     try {
-      const mailSettingParams = await updateMailSettinConfig({ 'mail:from': req.body.fromAddress });
-
+      const mailSettingParams = await updateMailSettinConfig(requestMailSettingParams);
       return res.apiv3({ mailSettingParams });
     }
     catch (err) {
-      const msg = 'Error occurred in updating from adress';
+      const msg = 'Error occurred in updating smtp setting';
       logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-from-adress-failed'));
+      return res.apiv3Err(new ErrorV3(msg, 'update-smtpSetting-failed'));
     }
-
   });
 
   /**
    * @swagger
    *
-   *    /app-settings/mail-setting:
-   *      put:
+   *    /app-settings/smtp-test:
+   *      post:
    *        tags: [AppSettings]
-   *        operationId: updateAppSettingMailSetting
-   *        summary: /app-settings/mail-setting
-   *        description: Update mail setting
-   *        requestBody:
-   *          required: true
-   *          content:
-   *            application/json:
-   *              schema:
-   *                $ref: '#/components/schemas/MailSettingParams'
+   *        operationId: postSmtpTest
+   *        summary: /app-settings/smtp-setting
+   *        description: Send test mail for smtp
    *        responses:
    *          200:
-   *            description: Succeeded to update mail setting
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  $ref: '#/components/schemas/MailSettingParams'
+   *            description: Succeeded to send test mail for smtp
    */
-  router.put('/mail-setting', loginRequiredStrictly, adminRequired, csrf, validator.mailSetting, apiV3FormValidator, async(req, res) => {
+  router.post('/smtp-test', loginRequiredStrictly, adminRequired, async(req, res) => {
     try {
-      await validateMailSetting(req);
+      await sendTestEmail(req.user.email);
+      return res.apiv3({});
     }
     catch (err) {
-      const msg = 'SMTPを利用したテストメール送信に失敗しました。設定をみなおしてください。';
+      const msg = req.t('validation.failed_to_send_a_test_email');
       logger.error('Error', err);
       debug('Error validate mail setting: ', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-mailSetting-failed'));
-    }
-
-
-    const requestMailSettingParams = {
-      'mail:smtpHost': req.body.smtpHost,
-      'mail:smtpPort': req.body.smtpPort,
-      'mail:smtpUser': req.body.smtpUser,
-      'mail:smtpPassword': req.body.smtpPassword,
-    };
-
-    try {
-      const mailSettingParams = await updateMailSettinConfig(requestMailSettingParams);
-      return res.apiv3({ mailSettingParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating mail setting';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-mailSetting-failed'));
+      return res.apiv3Err(new ErrorV3(msg, 'send-email-with-smtp-failed'));
     }
   });
 
   /**
    * @swagger
    *
-   *    /app-settings/mail-setting:
-   *      delete:
+   *    /app-settings/ses-setting:
+   *      put:
    *        tags: [AppSettings]
-   *        operationId: deleteAppSettingMailSetting
-   *        summary: /app-settings/mail-setting
-   *        description: delete mail setting
+   *        operationId: updateAppSettingSesSetting
+   *        summary: /app-settings/ses-setting
+   *        description: Update ses setting
    *        requestBody:
    *          required: true
    *          content:
    *            application/json:
    *              schema:
-   *                $ref: '#/components/schemas/MailSettingParams'
+   *                $ref: '#/components/schemas/SesSettingParams'
    *        responses:
    *          200:
-   *            description: Succeeded to delete mail setting
+   *            description: Succeeded to update ses setting
    *            content:
    *              application/json:
    *                schema:
-   *                  $ref: '#/components/schemas/MailSettingParams'
+   *                  $ref: '#/components/schemas/SesSettingParams'
    */
-  router.delete('/mail-setting', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
-    const requestMailSettingParams = {
-      'mail:smtpHost': null,
-      'mail:smtpPort': null,
-      'mail:smtpUser': null,
-      'mail:smtpPassword': null,
+  router.put('/ses-setting', loginRequiredStrictly, adminRequired, csrf, validator.sesSetting, apiV3FormValidator, async(req, res) => {
+    const { mailService } = crowi;
+
+    const requestSesSettingParams = {
+      'mail:from': req.body.fromAddress,
+      'mail:transmissionMethod': req.body.transmissionMethod,
+      'mail:sesAccessKeyId': req.body.sesAccessKeyId,
+      'mail:sesSecretAccessKey': req.body.sesSecretAccessKey,
     };
+
+    let mailSettingParams;
     try {
-      const mailSettingParams = await updateMailSettinConfig(requestMailSettingParams);
-      return res.apiv3({ mailSettingParams });
+      mailSettingParams = await updateMailSettinConfig(requestSesSettingParams);
     }
     catch (err) {
-      const msg = 'Error occurred in initializing mail setting';
+      const msg = 'Error occurred in updating ses setting';
       logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'initialize-mailSetting-failed'));
+      return res.apiv3Err(new ErrorV3(msg, 'update-ses-setting-failed'));
     }
+
+    await mailService.initialize();
+    mailService.publishUpdatedMessage();
+
+    return res.apiv3({ mailSettingParams });
   });
 
   /**
@@ -516,14 +540,11 @@ module.exports = (crowi) => {
     };
 
     try {
-      const { configManager, mailService } = crowi;
+      const { configManager } = crowi;
 
       // update config without publishing S2sMessage
       await configManager.updateConfigsInTheSameNamespace('crowi', requestAwsSettingParams, true);
 
-      await mailService.initialize();
-      mailService.publishUpdatedMessage();
-
       const awsSettingParams = {
         region: crowi.configManager.getConfig('crowi', 'aws:region'),
         customEndpoint: crowi.configManager.getConfig('crowi', 'aws:customEndpoint'),

+ 11 - 1
src/server/routes/apiv3/attachment.js

@@ -5,6 +5,7 @@ const logger = loggerFactory('growi:routes:apiv3:attachment'); // eslint-disable
 const express = require('express');
 
 const router = express.Router();
+const { query } = require('express-validator');
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
@@ -19,7 +20,16 @@ module.exports = (crowi) => {
   const loginRequired = require('../../middlewares/login-required')(crowi);
   const Page = crowi.model('Page');
   const Attachment = crowi.model('Attachment');
+  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
+
+  const validator = {
+    retrieveAttachments: [
+      query('pageId').isMongoId().withMessage('pageId is required'),
+      query('limit').isInt({ min: 1 }),
+      query('offset').isInt({ min: 0 }),
+    ],
+  };
   /**
    * @swagger
    *
@@ -38,7 +48,7 @@ module.exports = (crowi) => {
    *            schema:
    *              type: string
    */
-  router.get('/list', accessTokenParser, loginRequired, async(req, res) => {
+  router.get('/list', accessTokenParser, loginRequired, validator.retrieveAttachments, apiV3FormValidator, async(req, res) => {
     const offset = +req.query.offset || 0;
     const limit = +req.query.limit || 30;
     const queryOptions = { offset, limit };

+ 40 - 0
src/server/routes/apiv3/revisions.js

@@ -16,6 +16,46 @@ const PAGE_ITEMS = 30;
  *  tags:
  *    name: Revisions
  */
+
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      Revision:
+ *        description: Revision
+ *        type: object
+ *        properties:
+ *          _id:
+ *            type: string
+ *            description: revision ID
+ *            example: 5e0734e472560e001761fa68
+ *          __v:
+ *            type: number
+ *            description: DB record version
+ *            example: 0
+ *          author:
+ *            $ref: '#/components/schemas/User/properties/_id'
+ *          body:
+ *            type: string
+ *            description: content body
+ *            example: |
+ *              # test
+ *
+ *              test
+ *          format:
+ *            type: string
+ *            description: format
+ *            example: markdown
+ *          path:
+ *            type: string
+ *            description: path
+ *            example: /user/alice/test
+ *          createdAt:
+ *            type: string
+ *            description: date created at
+ *            example: 2010-01-01T00:00:00.000Z
+ */
 module.exports = (crowi) => {
   const certifySharedPage = require('../../middlewares/certify-shared-page')(crowi);
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);

+ 71 - 0
src/server/routes/apiv3/users.js

@@ -65,6 +65,8 @@ const validator = {};
  */
 
 module.exports = (crowi) => {
+  const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
+  const loginRequired = require('../../middlewares/login-required')(crowi, true);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
@@ -196,6 +198,73 @@ module.exports = (crowi) => {
     }
   });
 
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /{id}/recent:
+   *      get:
+   *        tags: [Users]
+   *        operationId: recent created page of user id
+   *        summary: /usersIdReacent
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            description: id of user
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: users recent created pages are fetched
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    paginateResult:
+   *                      $ref: '#/components/schemas/PaginateResult'
+   */
+  router.get('/:id/recent', accessTokenParser, loginRequired, async(req, res) => {
+    const { id } = req.params;
+
+    let user;
+
+    try {
+      user = await User.findById(id);
+    }
+    catch (err) {
+      const msg = 'Error occurred in find user';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'retrieve-recent-created-pages-failed'), 500);
+    }
+
+    if (user == null) {
+      return res.apiv3Err(new ErrorV3('find-user-is-not-found'));
+    }
+
+    const limit = parseInt(req.query.limit) || 50;
+    const offset = parseInt(req.query.offset) || 0;
+    const queryOptions = { offset, limit };
+
+    try {
+      const result = await Page.findListByCreator(user, req.user, queryOptions);
+
+      // Delete unnecessary data about users
+      result.pages = result.pages.map((page) => {
+        const user = page.lastUpdateUser.toObject();
+        page.lastUpdateUser = user;
+        return page;
+      });
+
+      return res.apiv3(result);
+    }
+    catch (err) {
+      const msg = 'Error occurred in retrieve recent created pages for user';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'retrieve-recent-created-pages-failed'), 500);
+    }
+  });
+
   validator.inviteEmail = [
     // isEmail prevents line breaks, so use isString
     body('shapedEmailList').custom((value) => {
@@ -252,6 +321,7 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3(err));
     }
   });
+
   /**
    * @swagger
    *
@@ -293,6 +363,7 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3(err));
     }
   });
+
   /**
    * @swagger
    *

+ 0 - 1
src/server/routes/index.js

@@ -134,7 +134,6 @@ module.exports = function(crowi, app) {
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
   app.get('/_api/users.list'          , accessTokenParser , loginRequired , user.api.list);
   app.get('/_api/pages.list'          , accessTokenParser , loginRequired , page.api.list);
-  app.get('/_api/pages.recentCreated' , accessTokenParser , loginRequired , page.api.recentCreated);
   app.post('/_api/pages.create'       , accessTokenParser , loginRequiredStrictly , csrf, page.api.create);
   app.post('/_api/pages.update'       , accessTokenParser , loginRequiredStrictly , csrf, page.api.update);
   app.get('/_api/pages.get'           , accessTokenParser , loginRequired , page.api.get);

+ 13 - 89
src/server/routes/page.js

@@ -219,10 +219,11 @@ module.exports = function(crowi, app) {
     }
   }
 
-  function addRendarVarsForPage(renderVars, page) {
+  function addRenderVarsForPage(renderVars, page) {
     renderVars.page = page;
+    renderVars.page.creator = renderVars.page.creator.toObject();
     renderVars.revision = page.revision;
-    renderVars.author = page.revision.author;
+    renderVars.revision.author = renderVars.revision.author.toObject();
     renderVars.pageIdOnHackmd = page.pageIdOnHackmd;
     renderVars.revisionHackmdSynced = page.revisionHackmdSynced;
     renderVars.hasDraftOnHackmd = page.hasDraftOnHackmd;
@@ -232,12 +233,12 @@ module.exports = function(crowi, app) {
     const userData = await User.findUserByUsername(User.getUsernameByPath(page.path));
 
     if (userData != null) {
-      renderVars.pageUser = userData;
+      renderVars.pageUser = userData.toObject();
       renderVars.bookmarkList = await Bookmark.findByUser(userData, { limit: 10, populatePage: true, requestUser });
     }
   }
 
-  function addRendarVarsForScope(renderVars, page) {
+  function addRenderVarsForScope(renderVars, page) {
     renderVars.grant = page.grant;
     renderVars.grantedGroupId = page.grantedGroup ? page.grantedGroup.id : null;
     renderVars.grantedGroupName = page.grantedGroup ? page.grantedGroup.name : null;
@@ -297,7 +298,7 @@ module.exports = function(crowi, app) {
 
     // populate
     page = await page.populateDataToMakePresentation(revisionId);
-    addRendarVarsForPage(renderVars, page);
+    addRenderVarsForPage(renderVars, page);
     return res.render('page_presentation', renderVars);
   }
 
@@ -315,7 +316,7 @@ module.exports = function(crowi, app) {
     // populate
     portalPage = await portalPage.populateDataToShowRevision();
 
-    addRendarVarsForPage(renderVars, portalPage);
+    addRenderVarsForPage(renderVars, portalPage);
     await addRenderVarsForSlack(renderVars, portalPage);
 
     const sharelinksNumber = await ShareLink.countDocuments({ relatedPage: portalPage._id });
@@ -359,8 +360,8 @@ module.exports = function(crowi, app) {
 
     // populate
     page = await page.populateDataToShowRevision();
-    addRendarVarsForPage(renderVars, page);
-    addRendarVarsForScope(renderVars, page);
+    addRenderVarsForPage(renderVars, page);
+    addRenderVarsForScope(renderVars, page);
 
     await addRenderVarsForSlack(renderVars, page);
     await addRenderVarsForDescendants(renderVars, path, req.user, offset, limit, true);
@@ -444,7 +445,7 @@ module.exports = function(crowi, app) {
       page = await page.populateDataToMakePresentation(revisionId);
 
       // populate
-      addRendarVarsForPage(renderVars, page);
+      addRenderVarsForPage(renderVars, page);
       return res.render('page_presentation', renderVars);
     }
 
@@ -452,8 +453,8 @@ module.exports = function(crowi, app) {
 
     // populate
     page = await page.populateDataToShowRevision();
-    addRendarVarsForPage(renderVars, page);
-    addRendarVarsForScope(renderVars, page);
+    addRenderVarsForPage(renderVars, page);
+    addRenderVarsForScope(renderVars, page);
 
     await interceptorManager.process('beforeRenderPage', req, res, renderVars);
     return res.render(view, renderVars);
@@ -522,7 +523,7 @@ module.exports = function(crowi, app) {
       const ancestor = await Page.findAncestorByPathAndViewer(path, req.user);
       if (ancestor != null) {
         await ancestor.populate('grantedGroup').execPopulate();
-        addRendarVarsForScope(renderVars, ancestor);
+        addRenderVarsForScope(renderVars, ancestor);
       }
     }
 
@@ -1563,82 +1564,5 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success(result));
   };
 
-  /**
-   * @swagger
-   *
-   *    /pages.recentCreated:
-   *      get:
-   *        tags: [Pages]
-   *        operationId: getRecentCreatedPages
-   *        summary: /pages.recentCreated
-   *        description: Get recent created page list
-   *        parameters:
-   *          - in: query
-   *            name: page_id
-   *            required: true
-   *            schema:
-   *              $ref: '#/components/schemas/Page/properties/_id'
-   *          - in: query
-   *            name: offset
-   *            schema:
-   *              $ref: '#/components/schemas/V1PaginateResult/properties/meta/properties/offset'
-   *          - in: query
-   *            name: limit
-   *            schema:
-   *              $ref: '#/components/schemas/V1PaginateResult/properties/meta/properties/limit'
-   *        responses:
-   *          200:
-   *            description: Succeeded to get recent created page list.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    pages:
-   *                      type: array
-   *                      description: recent created page list
-   *                      items:
-   *                        $ref: '#/components/schemas/Page'
-   *                    totalCount:
-   *                      $ref: '#/components/schemas/V1PaginateResult/properties/meta/properties/total'
-   *                    offset:
-   *                      $ref: '#/components/schemas/V1PaginateResult/properties/meta/properties/offset'
-   *                    limit:
-   *                      $ref: '#/components/schemas/V1PaginateResult/properties/meta/properties/limit'
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  api.recentCreated = async function(req, res) {
-    const pageId = req.query.page_id;
-
-    if (pageId == null) {
-      return res.json(ApiResponse.error('param \'pageId\' must not be null'));
-    }
-
-    const page = await Page.findById(pageId);
-    if (page == null) {
-      return res.json(ApiResponse.error(`Page (id='${pageId}') does not exist`));
-    }
-    if (!isUserPage(page.path)) {
-      return res.json(ApiResponse.error(`Page (id='${pageId}') is not a user home`));
-    }
-
-    const limit = +req.query.limit || 50;
-    const offset = +req.query.offset || 0;
-    const queryOptions = { offset, limit };
-
-    try {
-      const result = await Page.findListByCreator(page.creator, req.user, queryOptions);
-
-      return res.json(ApiResponse.success(result));
-    }
-    catch (err) {
-      return res.json(ApiResponse.error(err));
-    }
-  };
-
   return actions;
 };

+ 32 - 8
src/server/service/mail.js

@@ -20,6 +20,11 @@ class MailService extends S2sMessageHandlable {
     this.mailConfig = {};
     this.mailer = {};
 
+    /**
+     * the flag whether mailer is set up successfully
+     */
+    this.isMailerSetup = false;
+
     this.initialize();
   }
 
@@ -65,23 +70,29 @@ class MailService extends S2sMessageHandlable {
   initialize() {
     const { appService, configManager } = this;
 
+    this.isMailerSetup = false;
+
     if (!configManager.getConfig('crowi', 'mail:from')) {
       this.mailer = null;
       return;
     }
 
-    // Priority 1. SMTP
-    if (configManager.getConfig('crowi', 'mail:smtpHost') && configManager.getConfig('crowi', 'mail:smtpPort')) {
+    const transmissionMethod = configManager.getConfig('crowi', 'mail:transmissionMethod');
+
+    if (transmissionMethod === 'smtp') {
       this.mailer = this.createSMTPClient();
     }
-    // Priority 2. SES
-    else if (configManager.getConfig('crowi', 'aws:accessKeyId') && configManager.getConfig('crowi', 'aws:secretAccessKey')) {
+    else if (transmissionMethod === 'ses') {
       this.mailer = this.createSESClient();
     }
     else {
       this.mailer = null;
     }
 
+    if (this.mailer != null) {
+      this.isMailerSetup = true;
+    }
+
     this.mailConfig.from = configManager.getConfig('crowi', 'mail:from');
     this.mailConfig.subject = `${appService.getAppTitle()}からのメール`;
 
@@ -93,9 +104,15 @@ class MailService extends S2sMessageHandlable {
 
     logger.debug('createSMTPClient option', option);
     if (!option) {
+      const host = configManager.getConfig('crowi', 'mail:smtpHost');
+      const port = configManager.getConfig('crowi', 'mail:smtpPort');
+
+      if (host == null || port == null) {
+        return null;
+      }
       option = { // eslint-disable-line no-param-reassign
-        host: configManager.getConfig('crowi', 'mail:smtpHost'),
-        port: configManager.getConfig('crowi', 'mail:smtpPort'),
+        host,
+        port,
       };
 
       if (configManager.getConfig('crowi', 'mail:smtpUser') && configManager.getConfig('crowi', 'mail:smtpPassword')) {
@@ -113,6 +130,7 @@ class MailService extends S2sMessageHandlable {
     const client = nodemailer.createTransport(option);
 
     logger.debug('mailer set up for SMTP', client);
+
     return client;
   }
 
@@ -120,9 +138,14 @@ class MailService extends S2sMessageHandlable {
     const { configManager } = this;
 
     if (!option) {
+      const accessKeyId = configManager.getConfig('crowi', 'mail:sesAccessKeyId');
+      const secretAccessKey = configManager.getConfig('crowi', 'mail:sesSecretAccessKey');
+      if (accessKeyId == null || secretAccessKey == null) {
+        return null;
+      }
       option = { // eslint-disable-line no-param-reassign
-        accessKeyId: configManager.getConfig('crowi', 'aws:accessKeyId'),
-        secretAccessKey: configManager.getConfig('crowi', 'aws:secretAccessKey'),
+        accessKeyId,
+        secretAccessKey,
       };
     }
 
@@ -130,6 +153,7 @@ class MailService extends S2sMessageHandlable {
     const client = nodemailer.createTransport(ses(option));
 
     logger.debug('mailer set up for SES', client);
+
     return client;
   }
 

+ 8 - 2
src/test/models/shareLink.test.js

@@ -38,8 +38,14 @@ describe('ShareLink', () => {
       path: '/somePath',
       populateDataToShowRevision: () => {
         return {
-          revision: {},
-          creator: {},
+          revision: {
+            author: {
+              toObject: jest.fn(() => { return {} }),
+            },
+          },
+          creator: {
+            toObject: jest.fn(() => { return {} }),
+          },
         };
       },
       initLatestRevisionField: (revisionId) => {