From ebadd7fd3f255af3dd5035afe1d0d75337fa39a4 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Sat, 13 Feb 2021 12:28:26 +0900
Subject: [PATCH] wip: email notification

---
 locales/ja-JP.yml                             |   8 ++
 .../1613155914446-emailNotificationTypes.ts   |  14 ++
 migration/1613181457597-user-lang.ts          |  14 ++
 src/client/components/form/input.vue          | 120 ++++++++----------
 src/client/components/form/textarea.vue       | 101 +++++++++------
 src/client/i18n.ts                            |   2 +-
 .../pages/settings/email-notification.vue     |  90 +++++++++++++
 src/client/pages/settings/email.vue           |   7 +-
 src/client/pages/settings/index.vue           |   1 +
 src/client/pages/settings/profile.vue         |  46 ++++---
 src/client/sw/sw.ts                           |   2 +-
 src/{client/scripts => misc}/i18n.ts          |  17 +--
 src/models/entities/user-profile.ts           |  12 ++
 src/models/repositories/user.ts               |   4 +-
 src/server/api/endpoints/admin/send-email.ts  |   2 +-
 src/server/api/endpoints/i/update-email.ts    |   4 +-
 src/server/api/endpoints/i/update.ts          |   7 +-
 src/services/create-notification.ts           |  27 ++--
 src/services/send-email-notification.ts       |  28 ++++
 src/services/send-email.ts                    |   8 +-
 20 files changed, 355 insertions(+), 159 deletions(-)
 create mode 100644 migration/1613155914446-emailNotificationTypes.ts
 create mode 100644 migration/1613181457597-user-lang.ts
 create mode 100644 src/client/pages/settings/email-notification.vue
 rename src/{client/scripts => misc}/i18n.ts (66%)
 create mode 100644 src/services/send-email-notification.ts

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 5a7272b480..394577f378 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -437,6 +437,7 @@ signinWith: "{x}でログイン"
 signinFailed: "ログインできませんでした。ユーザー名とパスワードを確認してください。"
 tapSecurityKey: "セキュリティキーにタッチ"
 or: "もしくは"
+language: "言語"
 uiLanguage: "UIの表示言語"
 groupInvited: "グループに招待されました"
 aboutX: "{x}について"
@@ -701,6 +702,13 @@ inUse: "使用中"
 editCode: "コードを編集"
 apply: "適用"
 receiveAnnouncementFromInstance: "インスタンスからのお知らせを受け取る"
+emailNotification: "メール通知"
+
+_email:
+  _follow:
+    title: "フォローされました"
+  _receiveFollowRequest:
+    title: "フォローリクエストを受け取りました"
 
 _plugin:
   install: "プラグインのインストール"
diff --git a/migration/1613155914446-emailNotificationTypes.ts b/migration/1613155914446-emailNotificationTypes.ts
new file mode 100644
index 0000000000..d6908aecfa
--- /dev/null
+++ b/migration/1613155914446-emailNotificationTypes.ts
@@ -0,0 +1,14 @@
+import {MigrationInterface, QueryRunner} from "typeorm";
+
+export class emailNotificationTypes1613155914446 implements MigrationInterface {
+    name = 'emailNotificationTypes1613155914446'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "user_profile" ADD "emailNotificationTypes" jsonb NOT NULL DEFAULT '["follow","receiveFollowRequest","groupInvited"]'`);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "emailNotificationTypes"`);
+    }
+
+}
diff --git a/migration/1613181457597-user-lang.ts b/migration/1613181457597-user-lang.ts
new file mode 100644
index 0000000000..ac1fc88c99
--- /dev/null
+++ b/migration/1613181457597-user-lang.ts
@@ -0,0 +1,14 @@
+import {MigrationInterface, QueryRunner} from "typeorm";
+
+export class userLang1613181457597 implements MigrationInterface {
+    name = 'userLang1613181457597'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "user_profile" ADD "lang" character varying(32)`);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "lang"`);
+    }
+
+}
diff --git a/src/client/components/form/input.vue b/src/client/components/form/input.vue
index c8c22e95c7..f0aa6b0534 100644
--- a/src/client/components/form/input.vue
+++ b/src/client/components/form/input.vue
@@ -1,63 +1,50 @@
 <template>
-<div class="ztzhwixg _formItem" :class="{ inline, disabled }">
-	<div class="_formLabel"><slot></slot></div>
-	<div class="icon" ref="icon"><slot name="icon"></slot></div>
-	<div class="input _formPanel">
-		<div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div>
-		<input v-if="debounce" ref="inputEl"
-			v-debounce="500"
-			:type="type"
-			v-model.lazy="v"
-			:disabled="disabled"
-			:required="required"
-			:readonly="readonly"
-			:placeholder="placeholder"
-			:pattern="pattern"
-			:autocomplete="autocomplete"
-			:spellcheck="spellcheck"
-			:step="step"
-			@focus="focused = true"
-			@blur="focused = false"
-			@keydown="onKeydown($event)"
-			@input="onInput"
-			:list="id"
-		>
-		<input v-else ref="inputEl"
-			:type="type"
-			v-model="v"
-			:disabled="disabled"
-			:required="required"
-			:readonly="readonly"
-			:placeholder="placeholder"
-			:pattern="pattern"
-			:autocomplete="autocomplete"
-			:spellcheck="spellcheck"
-			:step="step"
-			@focus="focused = true"
-			@blur="focused = false"
-			@keydown="onKeydown($event)"
-			@input="onInput"
-			:list="id"
-		>
-		<datalist :id="id" v-if="datalist">
-			<option v-for="data in datalist" :value="data"/>
-		</datalist>
-		<div class="suffix" ref="suffixEl"><slot name="suffix"></slot></div>
+<FormGroup class="_formItem">
+	<template #label><slot></slot></template>
+	<div class="ztzhwixg _formItem" :class="{ inline, disabled }">
+		<div class="icon" ref="icon"><slot name="icon"></slot></div>
+		<div class="input _formPanel">
+			<div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div>
+			<input ref="inputEl"
+				:type="type"
+				v-model="v"
+				:disabled="disabled"
+				:required="required"
+				:readonly="readonly"
+				:placeholder="placeholder"
+				:pattern="pattern"
+				:autocomplete="autocomplete"
+				:spellcheck="spellcheck"
+				:step="step"
+				@focus="focused = true"
+				@blur="focused = false"
+				@keydown="onKeydown($event)"
+				@input="onInput"
+				:list="id"
+			>
+			<datalist :id="id" v-if="datalist">
+				<option v-for="data in datalist" :value="data"/>
+			</datalist>
+			<div class="suffix" ref="suffixEl"><slot name="suffix"></slot></div>
+		</div>
 	</div>
-	<button class="save _textButton" v-if="save && changed" @click="() => { changed = false; save(); }">{{ $ts.save }}</button>
-	<div class="_formCaption"><slot name="desc"></slot></div>
-</div>
+	<template #caption><slot name="desc"></slot></template>
+
+	<FormButton v-if="manualSave && changed" @click="updated" primary><Fa :icon="faSave"/> {{ $ts.save }}</FormButton>
+</FormGroup>
 </template>
 
 <script lang="ts">
 import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue';
-import debounce from 'v-debounce';
-import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons';
+import { faExclamationCircle, faSave } from '@fortawesome/free-solid-svg-icons';
 import './form.scss';
+import FormButton from './button.vue';
+import FormGroup from './group.vue';
 
 export default defineComponent({
-	directives: {
-		debounce
+	components: {
+		FormGroup,
+		FormButton,
 	},
 	props: {
 		value: {
@@ -101,9 +88,6 @@ export default defineComponent({
 		step: {
 			required: false
 		},
-		debounce: {
-			required: false
-		},
 		datalist: {
 			type: Array,
 			required: false,
@@ -113,9 +97,10 @@ export default defineComponent({
 			required: false,
 			default: false
 		},
-		save: {
-			type: Function,
+		manualSave: {
+			type: Boolean,
 			required: false,
+			default: false
 		},
 	},
 	emits: ['change', 'keydown', 'enter'],
@@ -144,15 +129,22 @@ export default defineComponent({
 			}
 		};
 
+		const updated = () => {
+			changed.value = false;
+			if (type?.value === 'number') {
+				context.emit('update:value', parseFloat(v.value));
+			} else {
+				context.emit('update:value', v.value);
+			}
+		};
+
 		watch(value, newValue => {
 			v.value = newValue;
 		});
 
 		watch(v, newValue => {
-			if (type?.value === 'number') {
-				context.emit('update:value', parseFloat(newValue));
-			} else {
-				context.emit('update:value', newValue);
+			if (!props.manualSave) {
+				updated();
 			}
 
 			invalid.value = inputEl.value.validity.badInput;
@@ -198,7 +190,8 @@ export default defineComponent({
 			focus,
 			onInput,
 			onKeydown,
-			faExclamationCircle,
+			updated,
+			faExclamationCircle, faSave,
 		};
 	},
 });
@@ -285,11 +278,6 @@ export default defineComponent({
 		}
 	}
 
-	> .save {
-		margin: 6px 0 0 0;
-		font-size: 0.8em;
-	}
-
 	&.inline {
 		display: inline-block;
 		margin: 0;
diff --git a/src/client/components/form/textarea.vue b/src/client/components/form/textarea.vue
index 711cd50124..135e16c259 100644
--- a/src/client/components/form/textarea.vue
+++ b/src/client/components/form/textarea.vue
@@ -1,29 +1,39 @@
 <template>
-<div class="rivhosbp _formItem" :class="{ tall, pre }">
-	<div class="_formLabel"><slot></slot></div>
-	<div class="input _formPanel">
-		<textarea ref="input" :class="{ code, _monospace: code }"
-			:value="value"
-			:required="required"
-			:readonly="readonly"
-			:pattern="pattern"
-			:autocomplete="autocomplete"
-			:spellcheck="!code"
-			@input="onInput"
-			@focus="focused = true"
-			@blur="focused = false"
-		></textarea>
+<FormGroup class="_formItem">
+	<template #label><slot></slot></template>
+	<div class="rivhosbp _formItem" :class="{ tall, pre }">
+		<div class="input _formPanel">
+			<textarea ref="input" :class="{ code, _monospace: code }"
+				v-model="v"
+				:required="required"
+				:readonly="readonly"
+				:pattern="pattern"
+				:autocomplete="autocomplete"
+				:spellcheck="!code"
+				@input="onInput"
+				@focus="focused = true"
+				@blur="focused = false"
+			></textarea>
+		</div>
 	</div>
-	<button class="save _textButton" v-if="save && changed" @click="() => { changed = false; save(); }">{{ $ts.save }}</button>
-	<div class="_formCaption"><slot name="desc"></slot></div>
-</div>
+	<template #caption><slot name="desc"></slot></template>
+
+	<FormButton v-if="manualSave && changed" @click="updated" primary><Fa :icon="faSave"/> {{ $ts.save }}</FormButton>
+</FormGroup>
 </template>
 
 <script lang="ts">
-import { defineComponent } from 'vue';
+import { defineComponent, ref, toRefs, watch } from 'vue';
+import { faSave } from '@fortawesome/free-solid-svg-icons';
 import './form.scss';
+import FormButton from './button.vue';
+import FormGroup from './group.vue';
 
 export default defineComponent({
+	components: {
+		FormGroup,
+		FormButton,
+	},
 	props: {
 		value: {
 			required: false
@@ -58,24 +68,46 @@ export default defineComponent({
 			required: false,
 			default: false
 		},
-		save: {
-			type: Function,
+		manualSave: {
+			type: Boolean,
 			required: false,
+			default: false
 		},
 	},
-	data() {
+	setup(props, context) {
+		const { value } = toRefs(props);
+		const v = ref(value.value);
+		const changed = ref(false);
+		const inputEl = ref(null);
+		const focus = () => inputEl.value.focus();
+		const onInput = (ev) => {
+			changed.value = true;
+			context.emit('change', ev);
+		};
+
+		const updated = () => {
+			changed.value = false;
+			context.emit('update:value', v.value);
+		};
+
+		watch(value, newValue => {
+			v.value = newValue;
+		});
+
+		watch(v, newValue => {
+			if (!props.manualSave) {
+				updated();
+			}
+		});
+		
 		return {
-			changed: false,
-		}
-	},
-	methods: {
-		focus() {
-			this.$refs.input.focus();
-		},
-		onInput(ev) {
-			this.changed = true;
-			this.$emit('update:value', ev.target.value);
-		}
+			v,
+			updated,
+			changed,
+			focus,
+			onInput,
+			faSave,
+		};
 	}
 });
 </script>
@@ -112,11 +144,6 @@ export default defineComponent({
 		}
 	}
 
-	> .save {
-		margin: 6px 0 0 0;
-		font-size: 0.8em;
-	}
-
 	&.tall {
 		> .input {
 			> textarea {
diff --git a/src/client/i18n.ts b/src/client/i18n.ts
index fbc10a0bad..6c29ef153f 100644
--- a/src/client/i18n.ts
+++ b/src/client/i18n.ts
@@ -1,6 +1,6 @@
 import { markRaw } from 'vue';
 import { locale } from '@/config';
-import { I18n } from '@/scripts/i18n';
+import { I18n } from '../misc/i18n';
 
 export const i18n = markRaw(new I18n(locale));
 
diff --git a/src/client/pages/settings/email-notification.vue b/src/client/pages/settings/email-notification.vue
new file mode 100644
index 0000000000..de2cfd3912
--- /dev/null
+++ b/src/client/pages/settings/email-notification.vue
@@ -0,0 +1,90 @@
+<template>
+<FormBase>
+	<FormGroup>
+		<FormSwitch v-model:value="mention">
+			{{ $ts._notification._types.mention }}
+		</FormSwitch>
+		<FormSwitch v-model:value="reply">
+			{{ $ts._notification._types.reply }}
+		</FormSwitch>
+		<FormSwitch v-model:value="quote">
+			{{ $ts._notification._types.quote }}
+		</FormSwitch>
+		<FormSwitch v-model:value="follow">
+			{{ $ts._notification._types.follow }}
+		</FormSwitch>
+		<FormSwitch v-model:value="receiveFollowRequest">
+			{{ $ts._notification._types.receiveFollowRequest }}
+		</FormSwitch>
+		<FormSwitch v-model:value="groupInvited">
+			{{ $ts._notification._types.groupInvited }}
+		</FormSwitch>
+	</FormGroup>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faCog } from '@fortawesome/free-solid-svg-icons';
+import { faBell, faEnvelope } from '@fortawesome/free-regular-svg-icons';
+import FormButton from '@/components/form/button.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormBase from '@/components/form/base.vue';
+import FormGroup from '@/components/form/group.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+	components: {
+		FormBase,
+		FormSwitch,
+		FormButton,
+		FormGroup,
+	},
+
+	emits: ['info'],
+	
+	data() {
+		return {
+			INFO: {
+				title: this.$ts.emailNotification,
+				icon: faEnvelope
+			},
+
+			mention: this.$i.emailNotificationTypes.includes('mention'),
+			reply: this.$i.emailNotificationTypes.includes('reply'),
+			quote: this.$i.emailNotificationTypes.includes('quote'),
+			follow: this.$i.emailNotificationTypes.includes('follow'),
+			receiveFollowRequest: this.$i.emailNotificationTypes.includes('receiveFollowRequest'),
+			groupInvited: this.$i.emailNotificationTypes.includes('groupInvited'),
+		}
+	},
+
+	created() {
+		this.$watch('mention', this.save);
+		this.$watch('reply', this.save);
+		this.$watch('quote', this.save);
+		this.$watch('follow', this.save);
+		this.$watch('receiveFollowRequest', this.save);
+		this.$watch('groupInvited', this.save);
+	},
+
+	mounted() {
+		this.$emit('info', this.INFO);
+	},
+
+	methods: {
+		save() {
+			os.api('i/update', {
+				emailNotificationTypes: [
+					...[this.mention ? 'mention' : null],
+					...[this.reply ? 'reply' : null],
+					...[this.quote ? 'quote' : null],
+					...[this.follow ? 'follow' : null],
+					...[this.receiveFollowRequest ? 'receiveFollowRequest' : null],
+					...[this.groupInvited ? 'groupInvited' : null],
+				].filter(x => x != null)
+			});
+		}
+	}
+});
+</script>
diff --git a/src/client/pages/settings/email.vue b/src/client/pages/settings/email.vue
index 5ccb79a41d..e334e23cbd 100644
--- a/src/client/pages/settings/email.vue
+++ b/src/client/pages/settings/email.vue
@@ -9,6 +9,11 @@
 		</FormLink>
 	</FormGroup>
 
+	<FormLink to="/settings/email/notification">
+		<template #icon><Fa :icon="faBell"/></template>
+		{{ $ts.emailNotification }}
+	</FormLink>
+
 	<FormSwitch :value="$i.receiveAnnouncementEmail" @update:value="onChangeReceiveAnnouncementEmail">
 		{{ $ts.receiveAnnouncementFromInstance }}
 	</FormSwitch>
@@ -43,7 +48,7 @@ export default defineComponent({
 				title: this.$ts.email,
 				icon: faEnvelope
 			},
-			faCog, faExclamationTriangle, faCheck
+			faCog, faExclamationTriangle, faCheck, faBell
 		}
 	},
 
diff --git a/src/client/pages/settings/index.vue b/src/client/pages/settings/index.vue
index df53eb5133..54bf56930d 100644
--- a/src/client/pages/settings/index.vue
+++ b/src/client/pages/settings/index.vue
@@ -99,6 +99,7 @@ export default defineComponent({
 				case 'general': return defineAsyncComponent(() => import('./general.vue'));
 				case 'email': return defineAsyncComponent(() => import('./email.vue'));
 				case 'email/address': return defineAsyncComponent(() => import('./email-address.vue'));
+				case 'email/notification': return defineAsyncComponent(() => import('./email-notification.vue'));
 				case 'theme': return defineAsyncComponent(() => import('./theme.vue'));
 				case 'theme/install': return defineAsyncComponent(() => import('./theme.install.vue'));
 				case 'theme/manage': return defineAsyncComponent(() => import('./theme.manage.vue'));
diff --git a/src/client/pages/settings/profile.vue b/src/client/pages/settings/profile.vue
index 8c2c63e317..e9bffc3b00 100644
--- a/src/client/pages/settings/profile.vue
+++ b/src/client/pages/settings/profile.vue
@@ -8,25 +8,30 @@
 		<FormButton @click="changeBanner" primary>{{ $ts._profile.changeBanner }}</FormButton>
 	</FormGroup>
 
-	<FormInput v-model:value="name" :max="30">
+	<FormInput v-model:value="name" :max="30" manual-save>
 		<span>{{ $ts._profile.name }}</span>
 	</FormInput>
 
-	<FormTextarea v-model:value="description" :max="500">
+	<FormTextarea v-model:value="description" :max="500" tall manual-save>
 		<span>{{ $ts._profile.description }}</span>
 		<template #desc>{{ $ts._profile.youCanIncludeHashtags }}</template>
 	</FormTextarea>
 
-	<FormInput v-model:value="location">
+	<FormInput v-model:value="location" manual-save>
 		<span>{{ $ts.location }}</span>
 		<template #prefix><Fa :icon="faMapMarkerAlt"/></template>
 	</FormInput>
 
-	<FormInput v-model:value="birthday" type="date">
+	<FormInput v-model:value="birthday" type="date" manual-save>
 		<span>{{ $ts.birthday }}</span>
 		<template #prefix><Fa :icon="faBirthdayCake"/></template>
 	</FormInput>
 
+	<FormSelect v-model:value="lang">
+		<template #label>{{ $ts.language }}</template>
+		<option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option>
+	</FormSelect>
+
 	<FormGroup>
 		<FormButton @click="editMetadata" primary>{{ $ts._profile.metadataEdit }}</FormButton>
 		<template #caption>{{ $ts._profile.metadataDescription }}</template>
@@ -37,8 +42,6 @@
 	<FormSwitch v-model:value="isBot">{{ $ts.flagAsBot }}<template #desc>{{ $ts.flagAsBotDescription }}</template></FormSwitch>
 
 	<FormSwitch v-model:value="alwaysMarkNsfw">{{ $ts.alwaysMarkSensitive }}</FormSwitch>
-
-	<FormButton @click="save(true)" primary><Fa :icon="faSave"/> {{ $ts.save }}</FormButton>
 </FormBase>
 </template>
 
@@ -50,10 +53,10 @@ import FormButton from '@/components/form/button.vue';
 import FormInput from '@/components/form/input.vue';
 import FormTextarea from '@/components/form/textarea.vue';
 import FormSwitch from '@/components/form/switch.vue';
-import FormTuple from '@/components/form/tuple.vue';
+import FormSelect from '@/components/form/select.vue';
 import FormBase from '@/components/form/base.vue';
 import FormGroup from '@/components/form/group.vue';
-import { host } from '@/config';
+import { host, langs } from '@/config';
 import { selectFile } from '@/scripts/select-file';
 import * as os from '@/os';
 
@@ -63,7 +66,7 @@ export default defineComponent({
 		FormInput,
 		FormTextarea,
 		FormSwitch,
-		FormTuple,
+		FormSelect,
 		FormBase,
 		FormGroup,
 	},
@@ -77,9 +80,11 @@ export default defineComponent({
 				icon: faUser
 			},
 			host,
+			langs,
 			name: null,
 			description: null,
 			birthday: null,
+			lang: null,
 			location: null,
 			fieldName0: null,
 			fieldValue0: null,
@@ -104,6 +109,7 @@ export default defineComponent({
 		this.description = this.$i.description;
 		this.location = this.$i.location;
 		this.birthday = this.$i.birthday;
+		this.lang = this.$i.lang;
 		this.avatarId = this.$i.avatarId;
 		this.bannerId = this.$i.bannerId;
 		this.isBot = this.$i.isBot;
@@ -118,6 +124,15 @@ export default defineComponent({
 		this.fieldValue2 = this.$i.fields[2] ? this.$i.fields[2].value : null;
 		this.fieldName3 = this.$i.fields[3] ? this.$i.fields[3].name : null;
 		this.fieldValue3 = this.$i.fields[3] ? this.$i.fields[3].value : null;
+
+		this.$watch('name', this.save);
+		this.$watch('description', this.save);
+		this.$watch('location', this.save);
+		this.$watch('birthday', this.save);
+		this.$watch('lang', this.save);
+		this.$watch('isBot', this.save);
+		this.$watch('isCat', this.save);
+		this.$watch('alwaysMarkNsfw', this.save);
 	},
 
 	mounted() {
@@ -214,14 +229,15 @@ export default defineComponent({
 			});
 		},
 
-		save(notify) {
+		save() {
 			this.saving = true;
 
-			os.api('i/update', {
+			os.apiWithDialog('i/update', {
 				name: this.name || null,
 				description: this.description || null,
 				location: this.location || null,
 				birthday: this.birthday || null,
+				lang: this.lang || null,
 				isBot: !!this.isBot,
 				isCat: !!this.isCat,
 				alwaysMarkNsfw: !!this.alwaysMarkNsfw,
@@ -231,16 +247,8 @@ export default defineComponent({
 				this.$i.avatarUrl = i.avatarUrl;
 				this.$i.bannerId = i.bannerId;
 				this.$i.bannerUrl = i.bannerUrl;
-
-				if (notify) {
-					os.success();
-				}
 			}).catch(err => {
 				this.saving = false;
-				os.dialog({
-					type: 'error',
-					text: err.id
-				});
 			});
 		},
 	}
diff --git a/src/client/sw/sw.ts b/src/client/sw/sw.ts
index a18d305ea1..c93fe4926d 100644
--- a/src/client/sw/sw.ts
+++ b/src/client/sw/sw.ts
@@ -5,7 +5,7 @@ declare var self: ServiceWorkerGlobalScope;
 
 import { get, set } from 'idb-keyval';
 import composeNotification from '@/sw/compose-notification';
-import { I18n } from '@/scripts/i18n';
+import { I18n } from '../../misc/i18n';
 
 //#region Variables
 const version = _VERSION_;
diff --git a/src/client/scripts/i18n.ts b/src/misc/i18n.ts
similarity index 66%
rename from src/client/scripts/i18n.ts
rename to src/misc/i18n.ts
index d535e236bb..4fa398763a 100644
--- a/src/client/scripts/i18n.ts
+++ b/src/misc/i18n.ts
@@ -1,14 +1,9 @@
-// Notice: Service Workerでも使用します
 export class I18n<T extends Record<string, any>> {
 	public locale: T;
 
 	constructor(locale: T) {
 		this.locale = locale;
 
-		if (_DEV_) {
-			console.log('i18n', this.locale);
-		}
-
 		//#region BIND
 		this.t = this.t.bind(this);
 		//#endregion
@@ -20,12 +15,6 @@ export class I18n<T extends Record<string, any>> {
 		try {
 			let str = key.split('.').reduce((o, i) => o[i], this.locale) as string;
 
-			if (_DEV_) {
-				if (!str.includes('{')) {
-					console.warn(`i18n: '${key}' has no any arg. so ref prop directly instead of call this method.`);
-				}
-			}
-
 			if (args) {
 				for (const [k, v] of Object.entries(args)) {
 					str = str.replace(`{${k}}`, v);
@@ -33,11 +22,7 @@ export class I18n<T extends Record<string, any>> {
 			}
 			return str;
 		} catch (e) {
-			if (_DEV_) {
-				console.warn(`missing localization '${key}'`);
-				return `⚠'${key}'⚠`;
-			}
-
+			console.warn(`missing localization '${key}'`);
 			return key;
 		}
 	}
diff --git a/src/models/entities/user-profile.ts b/src/models/entities/user-profile.ts
index 4fab52868f..3a9043fac6 100644
--- a/src/models/entities/user-profile.ts
+++ b/src/models/entities/user-profile.ts
@@ -4,6 +4,8 @@ import { User } from './user';
 import { Page } from './page';
 import { notificationTypes } from '../../types';
 
+// TODO: このテーブルで管理している情報すべてレジストリで管理するようにしても良いかも
+//       ただ、「emailVerified が true なユーザーを find する」のようなクエリは書けなくなるからウーン
 @Entity()
 export class UserProfile {
 	@PrimaryColumn(id())
@@ -41,6 +43,11 @@ export class UserProfile {
 		value: string;
 	}[];
 
+	@Column('varchar', {
+		length: 32, nullable: true,
+	})
+	public lang: string | null;
+
 	@Column('varchar', {
 		length: 512, nullable: true,
 		comment: 'Remote URL of the user.'
@@ -63,6 +70,11 @@ export class UserProfile {
 	})
 	public emailVerified: boolean;
 
+	@Column('jsonb', {
+		default: ['follow', 'receiveFollowRequest', 'groupInvited']
+	})
+	public emailNotificationTypes: string[];
+
 	@Column('varchar', {
 		length: 128, nullable: true,
 	})
diff --git a/src/models/repositories/user.ts b/src/models/repositories/user.ts
index 88861224a4..a3453b1aa6 100644
--- a/src/models/repositories/user.ts
+++ b/src/models/repositories/user.ts
@@ -213,6 +213,7 @@ export class UserRepository extends Repository<User> {
 				description: profile!.description,
 				location: profile!.location,
 				birthday: profile!.birthday,
+				lang: profile!.lang,
 				fields: profile!.fields,
 				followersCount: user.followersCount,
 				followingCount: user.followingCount,
@@ -258,7 +259,8 @@ export class UserRepository extends Repository<User> {
 				hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id),
 				integrations: profile!.integrations,
 				mutedWords: profile!.mutedWords,
-				mutingNotificationTypes: profile?.mutingNotificationTypes,
+				mutingNotificationTypes: profile!.mutingNotificationTypes,
+				emailNotificationTypes: profile!.emailNotificationTypes,
 			} : {}),
 
 			...(opts.includeSecrets ? {
diff --git a/src/server/api/endpoints/admin/send-email.ts b/src/server/api/endpoints/admin/send-email.ts
index 9af931ad99..c0e77e1621 100644
--- a/src/server/api/endpoints/admin/send-email.ts
+++ b/src/server/api/endpoints/admin/send-email.ts
@@ -22,5 +22,5 @@ export const meta = {
 };
 
 export default define(meta, async (ps) => {
-	await sendEmail(ps.to, ps.subject, ps.text);
+	await sendEmail(ps.to, ps.subject, ps.text, ps.text);
 });
diff --git a/src/server/api/endpoints/i/update-email.ts b/src/server/api/endpoints/i/update-email.ts
index 20d9703320..d3d7bace75 100644
--- a/src/server/api/endpoints/i/update-email.ts
+++ b/src/server/api/endpoints/i/update-email.ts
@@ -72,7 +72,9 @@ export default define(meta, async (ps, user) => {
 
 		const link = `${config.url}/verify-email/${code}`;
 
-		sendEmail(ps.email, 'Email verification', `To verify email, please click this link: ${link}`);
+		sendEmail(ps.email, 'Email verification',
+			`To verify email, please click this link:<br><a href="${link}">${link}</a>`,
+			`To verify email, please click this link: ${link}`);
 	}
 
 	return iObj;
diff --git a/src/server/api/endpoints/i/update.ts b/src/server/api/endpoints/i/update.ts
index e4c0e8cec9..bf1796924a 100644
--- a/src/server/api/endpoints/i/update.ts
+++ b/src/server/api/endpoints/i/update.ts
@@ -161,6 +161,10 @@ export const meta = {
 		mutingNotificationTypes: {
 			validator: $.optional.arr($.str.or(notificationTypes as unknown as string[]))
 		},
+
+		emailNotificationTypes: {
+			validator: $.optional.arr($.str)
+		},
 	},
 
 	errors: {
@@ -206,7 +210,7 @@ export default define(meta, async (ps, user, token) => {
 
 	if (ps.name !== undefined) updates.name = ps.name;
 	if (ps.description !== undefined) profileUpdates.description = ps.description;
-	//if (ps.lang !== undefined) updates.lang = ps.lang;
+	if (ps.lang !== undefined) profileUpdates.lang = ps.lang;
 	if (ps.location !== undefined) profileUpdates.location = ps.location;
 	if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday;
 	if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId;
@@ -226,6 +230,7 @@ export default define(meta, async (ps, user, token) => {
 	if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote;
 	if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail;
 	if (typeof ps.alwaysMarkNsfw === 'boolean') profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw;
+	if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes;
 
 	if (ps.avatarId) {
 		const avatar = await DriveFiles.findOne(ps.avatarId);
diff --git a/src/services/create-notification.ts b/src/services/create-notification.ts
index 5dddaa5727..6cd116040a 100644
--- a/src/services/create-notification.ts
+++ b/src/services/create-notification.ts
@@ -4,6 +4,7 @@ import { Notifications, Mutings, UserProfiles } from '../models';
 import { genId } from '../misc/gen-id';
 import { User } from '../models/entities/user';
 import { Notification } from '../models/entities/notification';
+import { sendEmailNotification } from './send-email-notification';
 
 export async function createNotification(
 	notifieeId: User['id'],
@@ -38,20 +39,22 @@ export async function createNotification(
 	setTimeout(async () => {
 		const fresh = await Notifications.findOne(notification.id);
 		if (fresh == null) return; // 既に削除されているかもしれない
-		if (!fresh.isRead) {
-			//#region ただしミュートしているユーザーからの通知なら無視
-			const mutings = await Mutings.find({
-				muterId: notifieeId
-			});
-			if (data.notifierId && mutings.map(m => m.muteeId).includes(data.notifierId)) {
-				return;
-			}
-			//#endregion
+		if (fresh.isRead) return;
 
-			publishMainStream(notifieeId, 'unreadNotification', packed);
-
-			pushSw(notifieeId, 'notification', packed);
+		//#region ただしミュートしているユーザーからの通知なら無視
+		const mutings = await Mutings.find({
+			muterId: notifieeId
+		});
+		if (data.notifierId && mutings.map(m => m.muteeId).includes(data.notifierId)) {
+			return;
 		}
+		//#endregion
+
+		publishMainStream(notifieeId, 'unreadNotification', packed);
+
+		pushSw(notifieeId, 'notification', packed);
+		if (type === 'follow') sendEmailNotification.follow(notifieeId, data);
+		if (type === 'receiveFollowRequest') sendEmailNotification.receiveFollowRequest(notifieeId, data);
 	}, 2000);
 
 	return notification;
diff --git a/src/services/send-email-notification.ts b/src/services/send-email-notification.ts
new file mode 100644
index 0000000000..7579d5b674
--- /dev/null
+++ b/src/services/send-email-notification.ts
@@ -0,0 +1,28 @@
+import { UserProfiles } from '../models';
+import { User } from '../models/entities/user';
+import { sendEmail } from './send-email';
+import * as locales from '../../locales/';
+import { I18n } from '../misc/i18n';
+
+// TODO: locale ファイルをクライアント用とサーバー用で分けたい
+
+async function follow(userId: User['id'], args: {}) {
+	const userProfile = await UserProfiles.findOneOrFail({ userId: userId });
+	if (!userProfile.email || !userProfile.emailNotificationTypes.includes('follow')) return;
+	const locale = locales[userProfile.lang || 'ja-JP'];
+	const i18n = new I18n(locale);
+	sendEmail(userProfile.email, i18n.t('_email._follow.title'), 'test', 'test');
+}
+
+async function receiveFollowRequest(userId: User['id'], args: {}) {
+	const userProfile = await UserProfiles.findOneOrFail({ userId: userId });
+	if (!userProfile.email || !userProfile.emailNotificationTypes.includes('receiveFollowRequest')) return;
+	const locale = locales[userProfile.lang || 'ja-JP'];
+	const i18n = new I18n(locale);
+	sendEmail(userProfile.email, i18n.t('_email._receiveFollowRequest.title'), 'test', 'test');
+}
+
+export const sendEmailNotification = {
+	follow,
+	receiveFollowRequest,
+};
diff --git a/src/services/send-email.ts b/src/services/send-email.ts
index 151228c7e3..c716b36715 100644
--- a/src/services/send-email.ts
+++ b/src/services/send-email.ts
@@ -5,7 +5,7 @@ import config from '../config';
 
 export const logger = new Logger('email');
 
-export async function sendEmail(to: string, subject: string, text: string) {
+export async function sendEmail(to: string, subject: string, html: string, text: string) {
 	const meta = await fetchMeta(true);
 
 	const iconUrl = `${config.url}/assets/mi-white.png`;
@@ -44,6 +44,9 @@ export async function sendEmail(to: string, subject: string, text: string) {
 
 						body {
 							padding: 16px;
+							margin: 0;
+							font-family: sans-serif;
+							font-size: 14px;
 						}
 
 						a {
@@ -67,6 +70,7 @@ export async function sendEmail(to: string, subject: string, text: string) {
 								main > header > img {
 									max-width: 128px;
 									max-height: 28px;
+									vertical-align: bottom;
 								}
 							main > article {
 								padding: 32px;
@@ -97,7 +101,7 @@ export async function sendEmail(to: string, subject: string, text: string) {
 						</header>
 						<article>
 							<h1>${ subject }</h1>
-							<div>${ text }</div>
+							<div>${ html }</div>
 						</article>
 						<footer>
 							<a href="${ emailSettingUrl }">${ 'Email setting' }</a>