├── public
├── logo.png
├── favicon.ico
├── demo
│ ├── img01.png
│ ├── img02.png
│ ├── img03.png
│ ├── img04.png
│ └── img05.png
├── images
│ ├── duck.png
│ ├── u2f-fail.png
│ ├── u2f-wait.png
│ ├── u2f-success.png
│ └── en_badge_web_generic.png
├── favicon-16x16.png
├── favicon-32x32.png
├── favicon-96x96.png
├── bootstrap-3.3.7
│ ├── fonts
│ │ ├── glyphicons-halflings-regular.eot
│ │ ├── glyphicons-halflings-regular.ttf
│ │ ├── glyphicons-halflings-regular.woff
│ │ └── glyphicons-halflings-regular.woff2
│ └── js
│ │ └── npm.js
├── css
│ ├── mail.css
│ ├── popup.css
│ └── wildduck.css
├── bootstrap-tagsinput-2.3.2
│ ├── bootstrap-tagsinput.less
│ ├── bootstrap-tagsinput-angular.min.js
│ ├── bootstrap-tagsinput.css
│ ├── bootstrap-tagsinput-angular.min.js.map
│ └── bootstrap-tagsinput-angular.js
├── wd.js
├── enable-totp.js
├── enable-u2f.js
└── login-key-handler.js
├── .gitignore
├── .ncurc.js
├── .github
├── FUNDING.yml
└── workflows
│ ├── docker-latest.yml
│ └── docker.yml
├── views
├── error.hbs
├── tos.hbs
├── partials
│ ├── accountmenu.hbs
│ ├── searchfield.hbs
│ ├── securitymenu.hbs
│ ├── header.hbs
│ ├── mailbox.hbs
│ ├── messagerow.hbs
│ ├── scripts.hbs
│ ├── identity.hbs
│ ├── tos.hbs
│ └── navbar.hbs
├── account
│ ├── security.hbs
│ ├── filters
│ │ ├── create.hbs
│ │ └── edit.hbs
│ ├── security
│ │ ├── asp.hbs
│ │ ├── enable-totp.hbs
│ │ ├── enable-u2f.hbs
│ │ ├── password.hbs
│ │ ├── events.hbs
│ │ ├── gpg.hbs
│ │ ├── asps.hbs
│ │ └── 2fa.hbs
│ ├── identities
│ │ ├── create.hbs
│ │ └── edit.hbs
│ ├── update-password.hbs
│ ├── login.hbs
│ ├── 2fa.hbs
│ ├── filters.hbs
│ ├── profile.hbs
│ ├── index.hbs
│ ├── restore.hbs
│ ├── identities.hbs
│ ├── autoreply.hbs
│ └── create.hbs
├── layout.hbs
├── webmail
│ ├── create.hbs
│ ├── mailbox.hbs
│ └── audit.hbs
├── layout-popup.hbs
├── layout-webmail.hbs
└── help.hbs
├── .bowerrc
├── .prettierrc.js
├── lib
├── db.js
├── hbs-helpers.js
├── tokens.js
├── http-sso.js
├── tools.js
└── passport.js
├── .eslintrc
├── Gruntfile.js
├── routes
├── index.js
└── account
│ ├── restore.js
│ └── autoreply.js
├── Dockerfile
├── bower.json
├── package.json
├── README.md
├── config
└── default.toml
├── server.js
└── app.js
/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nodemailer/wildduck-webmail/HEAD/public/logo.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nodemailer/wildduck-webmail/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/demo/img01.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nodemailer/wildduck-webmail/HEAD/public/demo/img01.png
--------------------------------------------------------------------------------
/public/demo/img02.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nodemailer/wildduck-webmail/HEAD/public/demo/img02.png
--------------------------------------------------------------------------------
/public/demo/img03.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nodemailer/wildduck-webmail/HEAD/public/demo/img03.png
--------------------------------------------------------------------------------
/public/demo/img04.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nodemailer/wildduck-webmail/HEAD/public/demo/img04.png
--------------------------------------------------------------------------------
/public/demo/img05.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nodemailer/wildduck-webmail/HEAD/public/demo/img05.png
--------------------------------------------------------------------------------
/public/images/duck.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nodemailer/wildduck-webmail/HEAD/public/images/duck.png
--------------------------------------------------------------------------------
/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nodemailer/wildduck-webmail/HEAD/public/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nodemailer/wildduck-webmail/HEAD/public/favicon-32x32.png
--------------------------------------------------------------------------------
/public/favicon-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nodemailer/wildduck-webmail/HEAD/public/favicon-96x96.png
--------------------------------------------------------------------------------
/public/images/u2f-fail.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nodemailer/wildduck-webmail/HEAD/public/images/u2f-fail.png
--------------------------------------------------------------------------------
/public/images/u2f-wait.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nodemailer/wildduck-webmail/HEAD/public/images/u2f-wait.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .DS_Store
3 | npm-debug.log
4 | package-lock.json
5 | public/components/*
6 | development.toml
--------------------------------------------------------------------------------
/public/images/u2f-success.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nodemailer/wildduck-webmail/HEAD/public/images/u2f-success.png
--------------------------------------------------------------------------------
/public/images/en_badge_web_generic.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nodemailer/wildduck-webmail/HEAD/public/images/en_badge_web_generic.png
--------------------------------------------------------------------------------
/.ncurc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | upgrade: true,
3 | reject: [
4 | // v4 is ESM only
5 | 'gravatar-url'
6 | ]
7 | };
8 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: [andris9] # enable once enrolled
4 | custom: ['https://www.paypal.me/nodemailer']
5 |
--------------------------------------------------------------------------------
/views/error.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
Two factor authentication
7 |
8 |
9 |
10 |
11 | Scan the code with an authenticator app and enter resulting security code below to verify
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | Security code
20 |
21 |
22 |
23 |
24 |
25 |
26 | Verify
27 |
28 |
Cancel
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/views/account/security/enable-u2f.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
Two factor authentication
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | Initializing...
31 |
32 |
33 |
36 |
37 |
38 |
39 |
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "wildduck-webmail",
3 | "version": "1.0.1",
4 | "private": true,
5 | "description": "WildDuck mail service",
6 | "main": "server.js",
7 | "scripts": {
8 | "test": "grunt",
9 | "bowerdeps": "mkdir -p public/components && bower install",
10 | "start": "node server.js"
11 | },
12 | "author": "Andris Reinman",
13 | "license": "EUPL-1.1+",
14 | "devDependencies": {
15 | "bower": "1.8.14",
16 | "eslint-config-nodemailer": "1.2.0",
17 | "eslint-config-prettier": "8.5.0",
18 | "grunt": "1.5.3",
19 | "grunt-cli": "1.4.3",
20 | "grunt-eslint": "24.0.0"
21 | },
22 | "dependencies": {
23 | "body-parser": "1.20.0",
24 | "connect-flash": "0.1.1",
25 | "connect-redis": "6.1.3",
26 | "cookie-parser": "1.4.6",
27 | "csurf": "1.11.0",
28 | "express": "4.18.1",
29 | "express-recaptcha": "5.1.0",
30 | "express-session": "1.17.3",
31 | "gravatar-url": "3.1.0",
32 | "hbs": "4.2.0",
33 | "he": "1.2.0",
34 | "humanize": "0.0.9",
35 | "ioredis": "5.2.1",
36 | "joi": "17.6.0",
37 | "morgan": "1.10.0",
38 | "multer": "1.4.4",
39 | "nodemailer": "6.7.7",
40 | "npmlog": "6.0.2",
41 | "passport": "0.6.0",
42 | "passport-local": "1.0.0",
43 | "pem": "1.14.6",
44 | "request": "2.88.2",
45 | "restify-clients": "4.1.1",
46 | "role-based-email-addresses": "1.3.0",
47 | "search-string": "3.1.0",
48 | "serve-favicon": "2.5.0",
49 | "wild-config": "1.6.1"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/views/webmail/create.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
14 |
15 |
47 |
--------------------------------------------------------------------------------
/lib/hbs-helpers.js:
--------------------------------------------------------------------------------
1 | /* eslint prefer-arrow-callback: 0, no-invalid-this: 0 */
2 |
3 | 'use strict';
4 |
5 | let hbs = require('hbs');
6 |
7 | hbs.registerPartials(__dirname + '/../views/partials');
8 |
9 | /**
10 | * We need this helper to make sure that we consume flash messages only
11 | * when we are able to actually display these. Otherwise we might end up
12 | * in a situation where we consume a flash messages but then comes a redirect
13 | * and the message is never displayed
14 | */
15 | hbs.registerHelper('flash_messages', function () {
16 | if (typeof this.flash !== 'function') {
17 | return '';
18 | }
19 |
20 | let messages = this.flash(); // eslint-disable-line no-invalid-this
21 | let response = [];
22 |
23 | // group messages by type
24 | Object.keys(messages).forEach((key) => {
25 | let el =
26 | '× ';
29 |
30 | if (key === 'danger') {
31 | el += '
';
32 | }
33 |
34 | let rows = [];
35 |
36 | messages[key].forEach((message) => {
37 | rows.push(hbs.handlebars.escapeExpression(message));
38 | });
39 |
40 | if (rows.length > 1) {
41 | el += '
' + rows.join('
\n
') + '
';
42 | } else {
43 | el += rows.join('');
44 | }
45 |
46 | el += '
';
47 |
48 | response.push(el);
49 | });
50 |
51 | return new hbs.handlebars.SafeString(response.join('\n'));
52 | });
53 |
--------------------------------------------------------------------------------
/lib/tokens.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const crypto = require('crypto');
4 | const config = require('wild-config');
5 |
6 | module.exports.TOKEN_2FA = 0x01;
7 | module.exports.TOKEN_RECOVERY = 0x02;
8 |
9 | module.exports.DAYS_2FA = 30;
10 | module.exports.DAYS_RECOVERY = 365;
11 |
12 | module.exports.generateToken = (user, scopeCode) => {
13 | scopeCode = (Number(scopeCode) || 1).toString(16);
14 | scopeCode = '0'.repeat(5 - scopeCode.length) + scopeCode;
15 | let parts = [scopeCode, Date.now().toString(16), crypto.randomBytes(6).toString('hex')];
16 |
17 | let valueStr = parts.join('::');
18 | let hash = crypto
19 | .createHmac('sha256', config.totp.secret + ':' + user)
20 | .update(valueStr)
21 | .digest('hex');
22 |
23 | return valueStr + '::' + hash;
24 | };
25 |
26 | module.exports.checkToken = (user, token, scopeCode) => {
27 | let parts = token.split('::');
28 | let hash = parts.pop();
29 | let timestamp = parseInt(parts[1], 16);
30 |
31 | if (parseInt(parts[1], 16) !== scopeCode) {
32 | return false;
33 | }
34 |
35 | let days;
36 | switch (scopeCode) {
37 | case module.exports.TOKEN_2FA:
38 | days = module.exports.DAYS_2FA;
39 | break;
40 | case module.exports.TOKEN_RECOVERY:
41 | days = module.exports.DAYS_RECOVERY;
42 | break;
43 | default:
44 | days = 10;
45 | }
46 |
47 | if (timestamp < Date.now() - days * 24 * 3600 * 1000) {
48 | return false;
49 | }
50 |
51 | return (
52 | hash ===
53 | crypto
54 | .createHmac('sha256', config.totp.secret + ':' + user)
55 | .update(parts.join('::'))
56 | .digest('hex')
57 | );
58 | };
59 |
--------------------------------------------------------------------------------
/views/layout-popup.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | {{serviceName}}
17 | {{#if title}} | {{title}}{{/if}}
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | {{flash_messages}}
30 |
31 |
32 | {{#if generalNotification}}
33 |
34 |
{{{generalNotification}}}
35 |
36 | {{/if}}
37 |
38 |
39 |
40 |
43 |
44 |
45 |
46 |
47 |
53 |
54 | {{> scripts}}
55 |
56 |
57 |
--------------------------------------------------------------------------------
/views/account/identities/create.hbs:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | {{> accountmenu}}
13 |
14 |
15 |
45 |
46 |
47 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # WildDuck Mail Service
2 |
3 | **What is this?**
4 |
5 | This is the default web service for [WildDuck email server](https://wildduck.email). The web service uses the [Wild Duck API](https://github.com/nodemailer/wildduck/wiki/API-Docs) to manage user settings and preview messages.
6 |
7 | ## Live demo
8 |
9 | There's a live demo up at https://wildduck.email – you can register a free @wildduck.email email address and try it out as a real email account.
10 |
11 | ## Usage
12 |
13 | Assuming that you have [WildDuck email server](https://wildduck.email) already running (check out [quick setup](https://github.com/nodemailer/wildduck/tree/master/setup)):
14 |
15 | ```
16 | $ npm install
17 | $ npm run bowerdeps
18 | $ node server.js
19 | ```
20 |
21 | You can also create an additional service specific configuration file that would be merged with the default config.
22 |
23 | ```
24 | $ node server.js --config="/etc/wildduck/www.toml"
25 | ```
26 |
27 | After you have started the server, head to http://localhost:3000/
28 |
29 | ## Screenshots
30 |
31 | 
32 |
33 | 
34 |
35 | 
36 |
37 | 
38 |
39 | 
40 |
41 | ### Message verification
42 |
43 | Message verification displays information about DKIM signature, SPF domain and TLS status in the last hop of transit.
44 |
45 | **Everything is OK:**
46 |
47 | 
48 |
49 | **Sender did not use TLS**
50 |
51 | 
52 |
53 | ## License
54 |
55 | [European Union Public License 1.1](http://ec.europa.eu/idabc/eupl.html) or later.
56 |
--------------------------------------------------------------------------------
/views/account/identities/edit.hbs:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | {{> accountmenu}}
13 |
14 |
15 |
45 |
46 |
47 |
--------------------------------------------------------------------------------
/views/account/update-password.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Change Password
4 |
5 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/views/partials/mailbox.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
Mailbox settings
5 |
6 |
7 |
8 | {{#if isInbox}}
9 |
10 |
11 | Mailbox name
12 |
13 | INBOX folder can not be modified
14 |
15 |
16 | {{else}}
17 |
18 |
19 | Mailbox Parent
20 |
21 | [No parent]
22 | {{#each parents}}
23 |
24 | {{name}}
25 |
26 | {{/each}}
27 |
28 |
29 | {{#if errors.parent}}
30 | {{errors.parent}}
31 | {{/if}}
32 |
33 |
34 |
35 | Mailbox name
36 |
37 | {{#if errors.name}}
38 | {{errors.name}}
39 | {{/if}}
40 |
41 | {{/if}}
42 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/.github/workflows/docker.yml:
--------------------------------------------------------------------------------
1 | name: Build and publish a Docker image to Docker hub and ghcr.io
2 | on:
3 | push:
4 | tags:
5 | - 'v*.*.*'
6 |
7 | jobs:
8 | docker:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Checkout
12 | uses: actions/checkout@v3
13 |
14 | - name: Docker meta
15 | id: meta
16 | uses: docker/metadata-action@v3
17 | with:
18 | images: |
19 | ${{ github.repository }}
20 | ghcr.io/${{ github.repository }}
21 | tags: |
22 | type=semver,pattern={{version}}
23 | type=semver,pattern={{major}}.{{minor}}
24 | type=semver,pattern={{major}}
25 |
26 | - name: Set up QEMU
27 | uses: docker/setup-qemu-action@v2
28 | with:
29 | platforms: 'arm64,arm'
30 |
31 | - name: Set up Docker Buildx
32 | id: buildx
33 | uses: docker/setup-buildx-action@v2
34 | with:
35 | platforms: linux/arm64,linux/amd64,linux/arm/v7
36 |
37 | - name: Login to DockerHub
38 | uses: docker/login-action@v2
39 | with:
40 | username: ${{ secrets.DOCKERHUB_USERNAME }}
41 | password: ${{ secrets.DOCKERHUB_TOKEN }}
42 |
43 | - name: Login to GHCR
44 | uses: docker/login-action@v2
45 | with:
46 | registry: ghcr.io
47 | username: ${{ github.repository_owner }}
48 | password: ${{ secrets.GITHUB_TOKEN }}
49 |
50 | - name: Build and push
51 | uses: docker/build-push-action@v3
52 | with:
53 | context: .
54 | platforms: ${{ steps.buildx.outputs.platforms }}
55 | push: true
56 | tags: ${{ steps.meta.outputs.tags }}
57 | labels: ${{ steps.meta.outputs.labels }}
58 |
--------------------------------------------------------------------------------
/public/bootstrap-tagsinput-2.3.2/bootstrap-tagsinput-angular.min.js.map:
--------------------------------------------------------------------------------
1 | {"version":3,"sources":["../src/bootstrap-tagsinput-angular.js"],"names":["angular","module","directive","getItemProperty","scope","property","isFunction","$parent","item","undefined","restrict","model","template","replace","link","element","attrs","$","isArray","select","typeaheadSourceArray","typeaheadSource","split","length","tagsinput","options","typeahead","source","itemValue","itemvalue","itemText","itemtext","confirmKeys","confirmkeys","JSON","parse","tagClass","tagclass","i","on","event","indexOf","push","idx","splice","prev","slice","$watch","added","filter","removed"],"mappings":";;;;;AAAAA,QAAQC,OAAO,0BACdC,UAAU,sBAAuB,WAEhC,QAASC,GAAgBC,EAAOC,GAC9B,MAAKA,GAGDL,QAAQM,WAAWF,EAAMG,QAAQF,IAC5BD,EAAMG,QAAQF,GAEhB,SAASG,GACd,MAAOA,GAAKH,IANLI,OAUX,OACEC,SAAU,KACVN,OACEO,MAAO,YAETC,SAAU,6BACVC,SAAS,EACTC,KAAM,SAASV,EAAOW,EAASC,GAC7BC,EAAE,WACKjB,QAAQkB,QAAQd,EAAMO,SACzBP,EAAMO,SAER,IAAIQ,GAASF,EAAE,SAAUF,GACrBK,EAAuBJ,EAAMK,gBAAkBL,EAAMK,gBAAgBC,MAAM,KAAO,KAClFD,EAAkBD,EACjBA,EAAqBG,OAAS,EAC3BnB,EAAMG,QAAQa,EAAqB,IAAIA,EAAqB,IAC1DhB,EAAMG,QAAQa,EAAqB,IACvC,IAEND,GAAOK,UAAUpB,EAAMG,QAAQS,EAAMS,SAAW,MAC9CC,WACEC,OAAW3B,QAAQM,WAAWe,GAAmBA,EAAkB,MAErEO,UAAWzB,EAAgBC,EAAOY,EAAMa,WACxCC,SAAW3B,EAAgBC,EAAOY,EAAMe,UACxCC,YAAc7B,EAAgBC,EAAOY,EAAMiB,aAAeC,KAAKC,MAAMnB,EAAMiB,cAAgB,IAC3FG,SAAWpC,QAAQM,WAAWF,EAAMG,QAAQS,EAAMqB,WAAajC,EAAMG,QAAQS,EAAMqB,UAAY,SAAS7B,GAAQ,MAAOQ,GAAMqB,WAG/H,KAAK,GAAIC,GAAI,EAAGA,EAAIlC,EAAMO,MAAMY,OAAQe,IACtCnB,EAAOK,UAAU,MAAOpB,EAAMO,MAAM2B,GAGtCnB,GAAOoB,GAAG,YAAa,SAASC,GACU,KAApCpC,EAAMO,MAAM8B,QAAQD,EAAMhC,OAC5BJ,EAAMO,MAAM+B,KAAKF,EAAMhC,QAG3BW,EAAOoB,GAAG,cAAe,SAASC,GAChC,GAAIG,GAAMvC,EAAMO,MAAM8B,QAAQD,EAAMhC,KACxB,MAARmC,GACFvC,EAAMO,MAAMiC,OAAOD,EAAK,IAK5B,IAAIE,GAAOzC,EAAMO,MAAMmC,OACvB1C,GAAM2C,OAAO,QAAS,WACpB,GAEIT,GAFAU,EAAQ5C,EAAMO,MAAMsC,OAAO,SAASX,GAAI,MAA2B,KAApBO,EAAKJ,QAAQH,KAC5DY,EAAUL,EAAKI,OAAO,SAASX,GAAI,MAAkC,KAA3BlC,EAAMO,MAAM8B,QAAQH,IAMlE,KAHAO,EAAOzC,EAAMO,MAAMmC,QAGdR,EAAI,EAAGA,EAAIY,EAAQ3B,OAAQe,IAC9BnB,EAAOK,UAAU,SAAU0B,EAAQZ,GAOrC,KAHAnB,EAAOK,UAAU,WAGZc,EAAI,EAAGA,EAAIU,EAAMzB,OAAQe,IAC5BnB,EAAOK,UAAU,MAAOwB,EAAMV,MAE/B","file":"bootstrap-tagsinput-angular.min.js"}
--------------------------------------------------------------------------------
/views/partials/messagerow.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | {{{fromHtml}}}
15 |
16 |
17 |
18 |
19 |
20 |
21 | {{#if mailboxName}}
22 | {{mailboxName}}
23 | {{/if}}
24 |
25 | {{subject}}{{#if intro}} – {{intro}} {{/if}}
26 |
27 |
28 |
29 |
30 |
31 |
32 | {{#if encrypted}}
33 |
34 | {{else}}
35 | {{#if attachments}}
36 |
37 | {{/if}}
38 | {{/if}}
39 |
40 |
41 |
42 |
43 |
44 |
45 | {{date}}
46 |
47 |
48 |
49 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/config/default.toml:
--------------------------------------------------------------------------------
1 | name="Wild Duck Mail"
2 |
3 | title="wildduck-www"
4 |
5 | [service]
6 | # email domain for new users
7 | domain="localhost"
8 | # default quotas for new users
9 | quota=1024
10 | recipients=2000
11 | forwards=2000
12 | identities=10
13 | allowIdentityEdit=true
14 | allowJoin=true
15 | enableSpecial=false # if true the allow creating addresses with special usernames
16 | # allowed domains for new addresses
17 | domains=["localhost"]
18 | # allow using addresses with other domains in the "From" field
19 | allowSendFromOtherDomains=true
20 |
21 | generalNotification="" # static notification to show on top of the page
22 |
23 | [service.sso.http]
24 | enabled = false
25 | header = "X-UserName" # value from this header is treated as logged in username
26 | authRedirect = "http:/127.0.0.1:3000/login" # URL to redirect non-authenticated users
27 | logoutRedirect = "http:/127.0.0.1:3000/logout" # URL to redirect when user clicks on "log out"
28 |
29 | [api]
30 | url="http://127.0.0.1:8080"
31 | accessToken=""
32 |
33 | [dbs]
34 | # redis connection string for Express sessions
35 | redis="redis://127.0.0.1:6379/5"
36 |
37 | [www]
38 | host=false
39 | port=3000
40 | proxy=false
41 | postsize="5MB"
42 | log="dev"
43 | secret="a cat"
44 | secure=false
45 | listSize=20
46 |
47 | [recaptcha]
48 | enabled=false
49 | siteKey=""
50 | secretKey=""
51 |
52 | [totp]
53 | # Issuer name for TOTP, defaults to config.name
54 | issuer=false
55 | # once setup do not change as it would invalidate all existing 2fa sessions
56 | secret="a secret cat"
57 |
58 | [u2f]
59 | # set to false if not using HTTPS
60 | enabled=true
61 | # must be https url or use default
62 | #appId="https://127.0.0.1:8080"
63 |
64 | [log]
65 | level="silly"
66 | mail=true
67 |
68 | [setup]
69 | # these values are shown in the configuration help page
70 | [setup.imap]
71 | hostname="localhost"
72 | secure=true
73 | port=9993
74 | [setup.pop3]
75 | hostname="localhost"
76 | secure=true
77 | port=9995
78 | [setup.smtp]
79 | hostname="localhost"
80 | secure=false
81 | port=2587
82 |
--------------------------------------------------------------------------------
/views/partials/scripts.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
27 |
28 | {{#if inboxId}}
29 |
40 | {{else}}
41 |
46 | {{/if}}
47 |
48 | {{#if successlog}}
49 |
50 |
57 | {{/if}}
58 |
59 |
72 |
--------------------------------------------------------------------------------
/public/wd.js:
--------------------------------------------------------------------------------
1 | /* eslint-env browser */
2 | /* global moment: false*/
3 |
4 | 'use strict';
5 |
6 | function updateDatestrings() {
7 | let elms = document.querySelectorAll('.datestring');
8 | let elm;
9 |
10 | for (let i = 0, len = elms.length; i < len; i++) {
11 | elm = elms[i];
12 | if (elm.title && elm.title.length === 24) {
13 | //elm.textContent = moment(elm.title).format('YYYY-MM-DD HH:mm:ss');
14 | elm.textContent = moment(elm.title).calendar(null, {
15 | lastDay: '[Yesterday at] LT',
16 | sameDay: '[Today at] LT',
17 | nextDay: '[Tomorrow at] LT',
18 | lastWeek: '[last] dddd [at] LT',
19 | nextWeek: 'dddd [at] LT',
20 | sameElse: 'DD/MM/YYYY LT'
21 | });
22 | }
23 | }
24 | }
25 |
26 | function updateFixedDatestrings() {
27 | let elms = document.querySelectorAll('.datestring-fixed');
28 | let elm;
29 |
30 | for (let i = 0, len = elms.length; i < len; i++) {
31 | elm = elms[i];
32 | if (elm.title && elm.title.length === 24) {
33 | elm.textContent = moment(elm.title).format('YYYY-MM-DD HH:mm');
34 | elm.textContent = moment(elm.title).calendar(null, {
35 | lastDay: 'D. MMM',
36 | sameDay: 'HH:mm',
37 | nextDay: 'D. MMM',
38 | lastWeek: 'D. MMM',
39 | nextWeek: 'D. MMM',
40 | sameElse: 'DD.MM.YY'
41 | });
42 | }
43 | }
44 | }
45 |
46 | function updateRelativeDatestrings() {
47 | let elms = document.querySelectorAll('.datestring-relative');
48 | let elm;
49 |
50 | for (let i = 0, len = elms.length; i < len; i++) {
51 | elm = elms[i];
52 | if (elm.title && elm.title.length === 24) {
53 | elm.textContent = moment(elm.title).fromNow();
54 | }
55 | }
56 | }
57 |
58 | // moment.locale('et');
59 | moment.updateLocale('en', {
60 | longDateFormat: {
61 | LT: 'H:mm',
62 | LTS: 'H:mm:ss',
63 | L: 'DD.MM.YYYY',
64 | LL: 'D. MMMM YYYY',
65 | LLL: 'D. MMMM YYYY H:mm',
66 | LLLL: 'dddd, D. MMMM YYYY H:mm'
67 | }
68 | });
69 |
70 | updateDatestrings();
71 | updateFixedDatestrings();
72 | updateRelativeDatestrings();
73 |
74 | setInterval(updateRelativeDatestrings, 10 * 1000);
75 | setInterval(updateDatestrings, 60 * 1000);
76 |
--------------------------------------------------------------------------------
/public/enable-totp.js:
--------------------------------------------------------------------------------
1 | /* eslint-env browser */
2 | /* eslint prefer-arrow-callback: 0, no-var: 0, object-shorthand: 0 */
3 | /* globals $:false */
4 |
5 | 'use strict';
6 |
7 | document.getElementById('totp-form').addEventListener(
8 | 'submit',
9 | function(e) {
10 | e.preventDefault();
11 | e.stopPropagation();
12 |
13 | var body = {
14 | _csrf: document.getElementById('_csrf').value,
15 | token: document.getElementById('token').value
16 | };
17 |
18 | var btn = $(document.getElementById('totp-btn'));
19 |
20 | btn.button('loading');
21 | fetch('/account/security/2fa/verify-totp', {
22 | method: 'post',
23 | headers: {
24 | Accept: 'application/json, text/plain, */*',
25 | 'Content-Type': 'application/json'
26 | },
27 | credentials: 'include',
28 | body: JSON.stringify(body)
29 | })
30 | .then(function(res) {
31 | return res.json();
32 | })
33 | .then(function(res) {
34 | btn.button('reset');
35 |
36 | if (res.error) {
37 | document.getElementById('totp-token-field').classList.add('has-error');
38 | $(document.getElementById('totp-token-error')).text(res.error);
39 | document.getElementById('totp-token-error').style.display = 'block';
40 | document.getElementById('token').focus();
41 | document.getElementById('token').select();
42 | return;
43 | }
44 |
45 | document.getElementById('totp-token-field').classList.remove('has-error');
46 | document.getElementById('totp-token-error').style.display = 'none';
47 |
48 | if (res.success && res.targetUrl) {
49 | window.location = res.targetUrl;
50 | }
51 | })
52 | .catch(function(err) {
53 | btn.button('reset');
54 | document.getElementById('totp-token-field').classList.add('has-error');
55 | $(document.getElementById('totp-token-error')).text(err.message);
56 | document.getElementById('totp-token-error').style.display = 'block';
57 | document.getElementById('token').focus();
58 | document.getElementById('token').select();
59 | });
60 | },
61 | false
62 | );
63 |
--------------------------------------------------------------------------------
/routes/account/restore.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const express = require('express');
4 | const router = new express.Router();
5 | const Joi = require('joi');
6 | const apiClient = require('../../lib/api-client');
7 |
8 | router.get('/', (req, res) => {
9 | res.render('account/restore', {
10 | title: 'Restore Messages',
11 | activeHome: true,
12 | accMenuRestore: true,
13 | values: {},
14 | csrfToken: req.csrfToken()
15 | });
16 | });
17 |
18 | router.post('/', (req, res) => {
19 | const updateSchema = Joi.object().keys({
20 | start: Joi.date().empty('').required(),
21 | end: Joi.date().empty('').min(Joi.ref('start')).required()
22 | });
23 |
24 | delete req.body._csrf;
25 | let result = updateSchema.validate(req.body, {
26 | abortEarly: false,
27 | convert: true,
28 | allowUnknown: false
29 | });
30 |
31 | let showErrors = (errors, disableDefault) => {
32 | if (!disableDefault) {
33 | req.flash('danger', 'Message restore failed');
34 | }
35 |
36 | res.render('account/restore', {
37 | title: 'Restore Messages',
38 | activeHome: true,
39 | accMenuRestore: true,
40 | values: result.value,
41 | errors,
42 | csrfToken: req.csrfToken()
43 | });
44 | };
45 |
46 | if (result.error) {
47 | let errors = {};
48 | if (result.error && result.error.details) {
49 | result.error.details.forEach(detail => {
50 | let path = detail.path;
51 | if (!errors[path]) {
52 | errors[path] = detail.message;
53 | }
54 | });
55 | }
56 |
57 | return showErrors(errors);
58 | }
59 |
60 | apiClient.restore.create(
61 | req.user,
62 | {
63 | start: result.value.start.toISOString(),
64 | end: result.value.end.toISOString(),
65 | sess: req.session.id,
66 | ip: req.ip
67 | },
68 | err => {
69 | if (err) {
70 | if (err.fields) {
71 | return showErrors(err.fields);
72 | } else {
73 | req.flash('danger', err.message);
74 | return showErrors({}, true);
75 | }
76 | }
77 |
78 | req.flash('success', 'Restoring was initiated. Please check back later for the restored messages.');
79 | res.redirect('/webmail');
80 | }
81 | );
82 | });
83 |
84 | module.exports = router;
85 |
--------------------------------------------------------------------------------
/views/layout-webmail.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{>header}}
6 |
7 |
8 |
9 |
10 | {{>navbar}}
11 |
12 |
13 | {{flash_messages}}
14 |
15 |
16 |
17 |
53 |
54 |
55 |
56 | {{#if generalNotification}}
57 |
58 |
{{{generalNotification}}}
59 |
60 | {{/if}}
61 |
62 | {{{body}}}
63 |
64 |
65 |
66 |
67 |
68 |
74 |
75 | {{> scripts}}
76 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/lib/http-sso.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const config = require('wild-config');
4 | const gravatarUrl = require('gravatar-url');
5 | const apiClient = require('./api-client');
6 |
7 | const getUserId = (req, res, done) => {
8 | let username = (req.headers[config.service.sso.http.header.toLowerCase()] || '').trim();
9 | if (!username) {
10 | // no username set
11 | return done(null, false);
12 | }
13 |
14 | if (req.session.ssoHttpUserName !== username) {
15 | // username has changed or set, resolve actual user
16 | return apiClient.users.resolve({ username, ip: req.ip, sess: req.session.id }, (err, result) => {
17 | if (err) {
18 | return done(err);
19 | }
20 |
21 | if (!result) {
22 | // unknown username
23 | return done(null, false);
24 | }
25 |
26 | req.session.ssoHttpUserName = username;
27 | req.session.ssoHttpUserId = result.id;
28 |
29 | return done(null, result.id);
30 | });
31 | }
32 |
33 | return done(null, req.session.ssoHttpUserId);
34 | };
35 |
36 | const setup = app => {
37 | app.use((req, res, next) => {
38 | res.locals.ssoEnabled = config.service.sso.http.enabled;
39 |
40 | getUserId(req, res, (err, id) => {
41 | if (err) {
42 | return next(err);
43 | }
44 |
45 | if (!id) {
46 | req.session.ssoHttpUserId = '';
47 | return req.session.save(() => {
48 | res.redirect(config.service.sso.http.authRedirect);
49 | });
50 | }
51 |
52 | apiClient.users.get({ id, ip: req.ip, sess: req.session.id }, (err, userData) => {
53 | if (err) {
54 | err.resetSession = true;
55 | return next(err);
56 | }
57 |
58 | if (!userData) {
59 | return req.session.save(() => {
60 | res.redirect(config.service.sso.http.authRedirect);
61 | });
62 | }
63 |
64 | apiClient.mailboxes.get(userData, 'resolve', { path: 'INBOX' }, (err, inboxData) => {
65 | if (err) {
66 | err.resetSession = true;
67 | return next(err);
68 | }
69 |
70 | userData.gravatar = gravatarUrl(userData.address || userData.username, {
71 | size: 20,
72 | // 404, mm, identicon, monsterid, wavatar, retro, blank
73 | default: 'identicon'
74 | });
75 |
76 | userData.inbox = inboxData;
77 | req.user = userData;
78 | next();
79 | });
80 | });
81 | });
82 | });
83 | };
84 |
85 | module.exports = { setup };
86 |
--------------------------------------------------------------------------------
/routes/account/autoreply.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const express = require('express');
4 | const router = new express.Router();
5 | const Joi = require('joi');
6 | const apiClient = require('../../lib/api-client');
7 |
8 | router.get('/', (req, res, next) => {
9 | apiClient.autoreply.get(req.user, (err, autoreply) => {
10 | if (err) {
11 | return next(err);
12 | }
13 | res.render('account/autoreply', {
14 | title: 'Autoreply',
15 | activeAutoreply: true,
16 | values: autoreply,
17 | csrfToken: req.csrfToken()
18 | });
19 | });
20 | });
21 |
22 | router.post('/', (req, res) => {
23 | const updateSchema = Joi.object().keys({
24 | status: Joi.boolean().required(),
25 | name: Joi.string().empty('').trim().max(128),
26 | subject: Joi.string().empty('').trim().max(128),
27 | text: Joi.string()
28 | .empty('')
29 | .trim()
30 | .max(10 * 1024),
31 | start: Joi.date().empty(''),
32 | end: Joi.date().empty('').min(Joi.ref('start'))
33 | });
34 |
35 | delete req.body._csrf;
36 | let result = updateSchema.validate(req.body, {
37 | abortEarly: false,
38 | convert: true,
39 | allowUnknown: false
40 | });
41 |
42 | let showErrors = (errors, disableDefault) => {
43 | if (!disableDefault) {
44 | req.flash('danger', 'Update failed');
45 | }
46 |
47 | res.render('account/autoreply', {
48 | title: 'Autoreply',
49 | activeAutoreply: true,
50 | values: result.value,
51 | errors,
52 |
53 | csrfToken: req.csrfToken()
54 | });
55 | };
56 |
57 | if (result.error) {
58 | let errors = {};
59 | if (result.error && result.error.details) {
60 | result.error.details.forEach(detail => {
61 | let path = detail.path;
62 | if (!errors[path]) {
63 | errors[path] = detail.message;
64 | }
65 | });
66 | }
67 |
68 | return showErrors(errors);
69 | }
70 |
71 | if (!result.value.name && 'name' in req.body) {
72 | result.value.name = '';
73 | }
74 |
75 | if (!result.value.subject && 'subject' in req.body) {
76 | result.value.subject = '';
77 | }
78 |
79 | if (!result.value.text && 'text' in req.body) {
80 | result.value.text = '';
81 | }
82 |
83 | apiClient.autoreply.update(req.user, result.value, err => {
84 | if (err) {
85 | if (err.fields) {
86 | return showErrors(err.fields);
87 | } else {
88 | req.flash('danger', err.message);
89 | return showErrors({}, true);
90 | }
91 | }
92 |
93 | req.flash('success', 'Autoreply is updated');
94 | res.redirect('/account/autoreply/');
95 | });
96 | });
97 |
98 | module.exports = router;
99 |
--------------------------------------------------------------------------------
/views/partials/identity.hbs:
--------------------------------------------------------------------------------
1 |
2 | Name
3 |
4 | {{#if errors.name}}
5 | {{errors.name}}
6 | {{else}}
7 | This name is used as the sender name when using this identity. Keep blank to default to your account name
8 | {{/if}}
9 |
10 |
11 |
33 |
34 | {{#unless isMain}}
35 |
42 | {{/unless}}
43 |
44 |
69 |
--------------------------------------------------------------------------------
/views/account/login.hbs:
--------------------------------------------------------------------------------
1 |
6 |
7 |
60 |
61 |
62 |
69 |
--------------------------------------------------------------------------------
/views/webmail/mailbox.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
21 |
22 |
23 |
24 |
25 |
29 |
30 | Are you sure you want to permanently delete {{mailbox.name}} and all its contents?
31 |
32 |
40 |
41 |
42 |
43 |
44 |
76 |
--------------------------------------------------------------------------------
/views/account/2fa.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
Two factor authentication
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | Initializing...
29 |
30 |
31 |
37 |
38 |
39 |
40 |
66 |
67 |
72 |
73 |
74 |
75 |
76 |
81 |
82 |
83 |
84 |
85 |
86 |
--------------------------------------------------------------------------------
/public/bootstrap-tagsinput-2.3.2/bootstrap-tagsinput-angular.js:
--------------------------------------------------------------------------------
1 | angular.module('bootstrap-tagsinput', [])
2 | .directive('bootstrapTagsinput', [function() {
3 |
4 | function getItemProperty(scope, property) {
5 | if (!property)
6 | return undefined;
7 |
8 | if (angular.isFunction(scope.$parent[property]))
9 | return scope.$parent[property];
10 |
11 | return function(item) {
12 | return item[property];
13 | };
14 | }
15 |
16 | return {
17 | restrict: 'EA',
18 | scope: {
19 | model: '=ngModel'
20 | },
21 | template: ' ',
22 | replace: false,
23 | link: function(scope, element, attrs) {
24 | $(function() {
25 | if (!angular.isArray(scope.model))
26 | scope.model = [];
27 |
28 | var select = $('select', element);
29 | var typeaheadSourceArray = attrs.typeaheadSource ? attrs.typeaheadSource.split('.') : null;
30 | var typeaheadSource = typeaheadSourceArray ?
31 | (typeaheadSourceArray.length > 1 ?
32 | scope.$parent[typeaheadSourceArray[0]][typeaheadSourceArray[1]]
33 | : scope.$parent[typeaheadSourceArray[0]])
34 | : null;
35 |
36 | select.tagsinput(scope.$parent[attrs.options || ''] || {
37 | typeahead : {
38 | source : angular.isFunction(typeaheadSource) ? typeaheadSource : null
39 | },
40 | itemValue: getItemProperty(scope, attrs.itemvalue),
41 | itemText : getItemProperty(scope, attrs.itemtext),
42 | confirmKeys : getItemProperty(scope, attrs.confirmkeys) ? JSON.parse(attrs.confirmkeys) : [13],
43 | tagClass : angular.isFunction(scope.$parent[attrs.tagclass]) ? scope.$parent[attrs.tagclass] : function(item) { return attrs.tagclass; }
44 | });
45 |
46 | for (var i = 0; i < scope.model.length; i++) {
47 | select.tagsinput('add', scope.model[i]);
48 | }
49 |
50 | select.on('itemAdded', function(event) {
51 | if (scope.model.indexOf(event.item) === -1)
52 | scope.model.push(event.item);
53 | });
54 |
55 | select.on('itemRemoved', function(event) {
56 | var idx = scope.model.indexOf(event.item);
57 | if (idx !== -1)
58 | scope.model.splice(idx, 1);
59 | });
60 |
61 | // create a shallow copy of model's current state, needed to determine
62 | // diff when model changes
63 | var prev = scope.model.slice();
64 | scope.$watch("model", function() {
65 | var added = scope.model.filter(function(i) {return prev.indexOf(i) === -1;}),
66 | removed = prev.filter(function(i) {return scope.model.indexOf(i) === -1;}),
67 | i;
68 |
69 | prev = scope.model.slice();
70 |
71 | // Remove tags no longer in binded model
72 | for (i = 0; i < removed.length; i++) {
73 | select.tagsinput('remove', removed[i]);
74 | }
75 |
76 | // Refresh remaining tags
77 | select.tagsinput('refresh');
78 |
79 | // Add new items in model as tags
80 | for (i = 0; i < added.length; i++) {
81 | select.tagsinput('add', added[i]);
82 | }
83 | }, true);
84 | });
85 | }
86 | };
87 | }]);
88 |
--------------------------------------------------------------------------------
/views/account/security/password.hbs:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | {{> securitymenu}}
13 |
14 |
15 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /**
4 | * Module dependencies.
5 | */
6 |
7 | const config = require('wild-config');
8 | const log = require('npmlog');
9 | const https = require('https');
10 | const http = require('http');
11 | const pem = require('pem');
12 | const db = require('./lib/db');
13 |
14 | const port = config.www.port;
15 | const host = config.www.host;
16 |
17 | if (config.title) {
18 | process.title = config.title;
19 | }
20 |
21 | log.level = config.log.level;
22 |
23 | // Initialize database connection
24 | db.connect(err => {
25 | if (err) {
26 | log.error('Db', 'Failed to setup database connection');
27 | return process.exit(1);
28 | }
29 |
30 | const app = require('./app'); // eslint-disable-line global-require
31 | app.set('port', port);
32 |
33 | /**
34 | * Create HTTP server.
35 | */
36 | let getServer = next => {
37 | if (config.www.secure) {
38 | return pem.createCertificate({ days: 1, selfSigned: true }, (err, keys) => {
39 | if (err) {
40 | throw err;
41 | }
42 | let server = https.createServer({ key: keys.serviceKey, cert: keys.certificate }, app);
43 |
44 | return next(null, server);
45 | });
46 | }
47 | next(null, http.createServer(app));
48 | };
49 |
50 | getServer((err, server) => {
51 | if (err) {
52 | throw err;
53 | }
54 | server.on('error', err => {
55 | if (err.syscall !== 'listen') {
56 | throw err;
57 | }
58 |
59 | let bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port;
60 |
61 | // handle specific listen errors with friendly messages
62 | switch (err.code) {
63 | case 'EACCES':
64 | log.error('Express', '%s requires elevated privileges', bind);
65 | return process.exit(1);
66 | case 'EADDRINUSE':
67 | log.error('Express', '%s is already in use', bind);
68 | return process.exit(1);
69 | default:
70 | throw err;
71 | }
72 | });
73 |
74 | server.on('listening', () => {
75 | let addr = server.address();
76 | let bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port;
77 | log.info('Express', 'WWW server listening on %s', bind);
78 |
79 | if (config.group) {
80 | try {
81 | process.setgid(config.group);
82 | log.info('Service', 'Changed group to "%s" (%s)', config.group, process.getgid());
83 | } catch (E) {
84 | log.error('Service', 'Failed to change group to "%s" (%s)', config.group, E.message);
85 | return process.exit(1);
86 | }
87 | }
88 |
89 | if (config.user) {
90 | try {
91 | process.setuid(config.user);
92 | log.info('Service', 'Changed user to "%s" (%s)', config.user, process.getuid());
93 | } catch (E) {
94 | log.info('Service', 'Failed to change user to "%s" (%s)', config.user, E.message);
95 | return process.exit(1);
96 | }
97 | }
98 | });
99 |
100 | server.listen(port, host);
101 | });
102 | });
103 |
--------------------------------------------------------------------------------
/public/enable-u2f.js:
--------------------------------------------------------------------------------
1 | /* eslint-env browser */
2 | /* eslint prefer-arrow-callback: 0, no-var: 0, object-shorthand: 0 */
3 | /* globals $:false, u2f: false, U2FSUPPORT: false */
4 |
5 | 'use strict';
6 |
7 | var message = document.getElementById('message');
8 |
9 | if (U2FSUPPORT) {
10 | document.addEventListener('DOMContentLoaded', function() {
11 | fetch('/account/security/2fa/setup-u2f', {
12 | method: 'post',
13 | headers: {
14 | Accept: 'application/json, text/plain, */*',
15 | 'Content-Type': 'application/json'
16 | },
17 | credentials: 'include',
18 | body: JSON.stringify({ _csrf: document.getElementById('_csrf').value })
19 | })
20 | .then(function(res) {
21 | return res.json();
22 | })
23 | .then(function(res) {
24 | if (res.error) {
25 | $(message).text(res.error);
26 | message.classList.add('text-danger');
27 | document.getElementById('u2f-fail').style.display = 'block';
28 | return;
29 | }
30 |
31 | let appId = res.u2fRegRequest.appId;
32 | let regRequest = {
33 | version: res.u2fRegRequest.version,
34 | challenge: res.u2fRegRequest.challenge
35 | };
36 |
37 | $(message).text('Push the button on your U2F key...');
38 |
39 | u2f.register(appId, [regRequest], [], function(regResponse) {
40 | $(message).text('Verifying response...');
41 |
42 | regResponse._csrf = document.getElementById('_csrf').value;
43 | fetch('/account/security/2fa/enable-u2f/verify', {
44 | method: 'post',
45 | headers: {
46 | Accept: 'application/json, text/plain, */*',
47 | 'Content-Type': 'application/json'
48 | },
49 | credentials: 'include',
50 | body: JSON.stringify(regResponse)
51 | })
52 | .then(function(res) {
53 | return res.json();
54 | })
55 | .then(function(res) {
56 | document.getElementById('u2f-wait').style.display = 'none';
57 | if (res.error) {
58 | $(message).text(res.error);
59 | document.getElementById('u2f-fail').style.display = 'block';
60 | message.classList.add('text-danger');
61 | return;
62 | }
63 | document.getElementById('u2f-success').style.display = 'block';
64 | $(message).text(res.success ? 'U2F key was added to your account' : 'Failed to register U2F key');
65 | message.classList.remove('text-danger');
66 |
67 | if (res.success && res.targetUrl) {
68 | window.location = res.targetUrl;
69 | }
70 | })
71 | .catch(function(err) {
72 | $(message).text(err.message);
73 | message.classList.add('text-danger');
74 | });
75 | });
76 | })
77 | .catch(function(err) {
78 | $(message).text(err.message);
79 | message.classList.add('text-danger');
80 | });
81 | });
82 | } else {
83 | document.getElementById('u2f-wait').style.display = 'none';
84 | document.getElementById('u2f-fail').style.display = 'block';
85 | message.classList.add('text-danger');
86 | $(message).text('U2F is not supported by your browser');
87 | }
88 |
--------------------------------------------------------------------------------
/views/account/filters.hbs:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
Mail Filters
13 |
14 |
Here you can create and modify filters that apply on all incoming messages.
15 |
16 |
17 |
18 | {{#if filters}}
19 | {{#each filters}}
20 |
21 |
22 | {{index}}
23 |
24 |
25 |
26 |
Edit
27 |
28 |
Delete
29 |
30 |
31 | Query: {{query}} Action: {{action}}
32 |
33 |
34 |
35 | {{/each}}
36 | {{else}}
37 |
38 |
39 | There are no filters created
40 |
41 |
42 | {{/if}}
43 |
44 |
45 |
46 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
65 |
66 | Are you sure you want to permanently delete selected filter?
67 |
68 |
76 |
77 |
78 |
79 |
80 |
89 |
--------------------------------------------------------------------------------
/lib/tools.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const punycode = require('punycode');
4 | const he = require('he');
5 | const Joi = require('joi');
6 | const config = require('wild-config');
7 |
8 | function getAddressesHTML(value, noLinks) {
9 | let formatSingleLevel = addresses =>
10 | addresses
11 | .map(address => {
12 | let str = '';
13 | /*
14 | if (address.name) {
15 |
16 | str += '' + he.encode(address.name) + (address.group ? ': ' : '') + ' ';
17 | }
18 | */
19 | if (address.address) {
20 | let link;
21 | if (noLinks) {
22 | link =
23 | '' +
26 | he.encode(address.name || address.address) +
27 | ' ';
28 | } else {
29 | link =
30 | '' +
35 | he.encode(address.name || address.address) +
36 | ' ';
37 | }
38 | //if (!address.name) {
39 | // str += ' <' + link + '>';
40 | //} else {
41 | str += link;
42 | //}
43 | }
44 | if (address.group) {
45 | str += formatSingleLevel(address.group) + ';';
46 | }
47 | return str + ' ';
48 | })
49 | .join(', ');
50 | return formatSingleLevel([].concat(value || []));
51 | }
52 |
53 | function normalizeDomain(domain) {
54 | domain = (domain || '').toLowerCase().trim();
55 | try {
56 | if (/^xn--/.test(domain)) {
57 | domain = punycode.toUnicode(domain).normalize('NFC').toLowerCase().trim();
58 | }
59 | } catch (E) {
60 | // ignore
61 | }
62 |
63 | return domain;
64 | }
65 |
66 | function normalizeAddress(address, withNames, options) {
67 | if (typeof address === 'string') {
68 | address = {
69 | address
70 | };
71 | }
72 | if (!address || !address.address) {
73 | return '';
74 | }
75 |
76 | options = options || {};
77 |
78 | let removeLabel = typeof options.removeLabel === 'boolean' ? options.removeLabel : false;
79 | let removeDots = typeof options.removeDots === 'boolean' ? options.removeDots : false;
80 |
81 | let user = address.address.substr(0, address.address.lastIndexOf('@')).normalize('NFC').toLowerCase().trim();
82 |
83 | if (removeLabel) {
84 | user = user.replace(/\+[^@]*$/, '');
85 | }
86 |
87 | if (removeDots) {
88 | user = user.replace(/\./g, '');
89 | }
90 |
91 | let domain = normalizeDomain(address.address.substr(address.address.lastIndexOf('@') + 1));
92 |
93 | let addr = user + '@' + domain;
94 |
95 | if (withNames) {
96 | return {
97 | name: address.name || '',
98 | address: addr
99 | };
100 | }
101 |
102 | return addr;
103 | }
104 |
105 | function filterIfSendingAllowed({ address }) {
106 | const allowSendFromOtherDomains = config.service.allowSendFromOtherDomains;
107 |
108 | if (allowSendFromOtherDomains) {
109 | return true;
110 | }
111 |
112 | const domains = config.service.domains;
113 | const domain = normalizeDomain(address.substr(address.lastIndexOf('@') + 1));
114 |
115 | return domains.includes(domain);
116 | }
117 |
118 | module.exports = {
119 | getAddressesHTML,
120 | normalizeAddress,
121 | normalizeDomain,
122 | filterIfSendingAllowed,
123 |
124 | booleanSchema: Joi.boolean().empty('').truthy('Y', 'true', 'yes', 'on', '1', 1).falsy('N', 'false', 'no', 'off', '0', 0)
125 | };
126 |
--------------------------------------------------------------------------------
/views/webmail/audit.hbs:
--------------------------------------------------------------------------------
1 |
16 |
17 |
18 | Below are displayed timeline events related to the selected message. This includes receive info, forwarding and autoreplies
19 |
20 |
21 | {{#each events}}
22 |
23 |
24 | {{#if actionDescription}}
25 | {{#if action}}
26 | Action
27 | {{actionDescription}}
28 | {{/if}}
29 | {{else}}
30 | {{#if action}}
31 | Action
32 | {{action}}
33 | {{/if}}
34 | {{/if}}
35 |
36 | ID
37 | {{id}}{{#if seq}}.{{seq}}{{/if}}
38 |
39 | Time
40 | {{time}}
41 |
42 | {{#if messageId}}
43 | Message-ID
44 | {{messageId}}
45 | {{/if}}
46 |
47 | {{#if from}}
48 | From
49 | {{from}}
50 | {{/if}}
51 |
52 | {{#if to}}
53 | To
54 | {{to}}
55 | {{/if}}
56 |
57 | {{#if targetList}}
58 | {{#if toTitle}}{{toTitle}}{{else}}Forwarding{{/if}}
59 |
60 | {{#each targetList}}
61 | {{../id}}.{{seq}}: {{text}} {{value}}
62 | {{/each}}
63 |
64 | {{/if}}
65 |
66 | {{#if origin}}
67 | Sending host
68 | {{origin}}
69 | {{/if}}
70 |
71 | {{#if src}}
72 | Local address
73 | {{src}}
74 | {{/if}}
75 |
76 | {{#if mx}}
77 | Destination
78 | {{mx}}
79 | {{#if dst}}
80 | [{{dst}}]
81 | {{/if}}
82 |
83 | {{/if}}
84 |
85 | {{#if response}}
86 | Server response
87 | {{response}}
88 | {{/if}}
89 |
90 | {{#if error}}
91 | Error message
92 | {{error}}
93 | {{/if}}
94 |
95 |
96 | {{/each}}
97 |
98 |
99 |
100 |
101 |
102 |
135 |
--------------------------------------------------------------------------------
/views/partials/tos.hbs:
--------------------------------------------------------------------------------
1 | Last updated: January 24, 2018
2 |
3 |
4 | Please read these Terms and Conditions ("Terms", "Terms and Conditions") carefully before using the http://{{serviceDomain}} website (the "Service") operated by {{serviceName}} ("us", "we", or "our").
5 |
6 | Your access to and use of the Service is conditioned on your acceptance of and compliance with these Terms. These Terms apply to all visitors, users and others who access or use the Service.
7 |
8 | By accessing or using the Service you agree to be bound by these Terms. If you disagree with any part of the terms then you may not access the Service. Terms and Conditions for {{serviceName}} based on the T&C example from TermsFeed .
9 |
10 | Accounts
11 |
12 | When you create an account with us, you must provide us information that is accurate, complete, and current at all times. Failure to do so constitutes a breach of the Terms, which may result in immediate termination of your account on our Service.
13 |
14 | You are responsible for safeguarding the password that you use to access the Service and for any activities or actions under your password, whether your password is with our Service or a third-party service.
15 |
16 | You agree not to disclose your password to any third party. You must notify us immediately upon becoming aware of any breach of security or unauthorized use of your account.
17 |
18 |
19 | Links To Other Web Sites
20 |
21 | Our Service may contain links to third-party web sites or services that are not owned or controlled by {{serviceName}}.
22 |
23 | {{serviceName}} has no control over, and assumes no responsibility for, the content, privacy policies, or practices of any third party web sites or services. You further acknowledge and agree that {{serviceName}} shall not be responsible or liable, directly or indirectly, for any damage or loss caused or alleged to be caused by or in connection with use of or reliance on any such content, goods or services available on or through any such web sites or services.
24 |
25 | We strongly advise you to read the terms and conditions and privacy policies of any third-party web sites or services that you visit.
26 |
27 |
28 | Termination
29 |
30 | We may terminate or suspend access to our Service immediately, without prior notice or liability, for any reason whatsoever, including without limitation if you breach the Terms.
31 |
32 | All provisions of the Terms which by their nature should survive termination shall survive termination, including, without limitation, ownership provisions, warranty disclaimers, indemnity and limitations of liability.
33 |
34 | We may terminate or suspend your account immediately, without prior notice or liability, for any reason whatsoever, including without limitation if you breach the Terms.
35 |
36 | Upon termination, your right to use the Service will immediately cease. If you wish to terminate your account, you may simply discontinue using the Service.
37 |
38 | All provisions of the Terms which by their nature should survive termination shall survive termination, including, without limitation, ownership provisions, warranty disclaimers, indemnity and limitations of liability.
39 |
40 |
41 | Governing Law
42 |
43 | These Terms shall be governed and construed in accordance with the laws of Estonia, without regard to its conflict of law provisions.
44 |
45 | Our failure to enforce any right or provision of these Terms will not be considered a waiver of those rights. If any provision of these Terms is held to be invalid or unenforceable by a court, the remaining provisions of these Terms will remain in effect. These Terms constitute the entire agreement between us regarding our Service, and supersede and replace any prior agreements we might have between us regarding the Service.
46 |
47 |
48 | Changes
49 |
50 | We reserve the right, at our sole discretion, to modify or replace these Terms at any time. If a revision is material we will try to provide at least 30 days notice prior to any new terms taking effect. What constitutes a material change will be determined at our sole discretion.
51 |
52 | By continuing to access or use our Service after those revisions become effective, you agree to be bound by the revised terms. If you do not agree to the new terms, please stop using the Service.
53 |
54 |
55 | Contact Us
56 |
57 | If you have any questions about these Terms, please contact us.
58 |
--------------------------------------------------------------------------------
/views/partials/navbar.hbs:
--------------------------------------------------------------------------------
1 |
2 |
90 |
--------------------------------------------------------------------------------
/views/account/profile.hbs:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | {{> accountmenu}}
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
95 |
96 |
97 |
98 |
99 |
--------------------------------------------------------------------------------
/views/account/index.hbs:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | {{> accountmenu}}
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
31 |
32 |
37 |
38 |
46 |
47 |
48 |
49 | {{storageOverview}}%
50 |
51 |
52 |
53 |
61 |
62 |
63 |
64 | {{recipientsOverview}}%
65 |
66 |
67 |
68 |
76 |
77 |
78 |
79 | {{forwardsOverview}}%
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
104 |
--------------------------------------------------------------------------------
/views/account/security/events.hbs:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | {{> securitymenu}}
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | Environment
25 |
26 |
27 | Action
28 |
29 |
30 | Result
31 |
32 |
33 | IP
34 |
35 |
36 | Session
37 |
38 |
39 | Time
40 |
41 |
42 |
43 |
44 | {{#if results}}
45 |
46 | {{#each results}}
47 |
48 |
49 | {{protocol}}
50 |
51 |
52 |
53 |
54 | {{#if asp}}
55 |
56 | {{asp.name}}
57 |
58 | {{/if}}
59 |
60 | {{action}}
61 |
62 | ({{events}})
63 |
64 |
65 | {{#if label}}
66 | {{result}}
67 | {{else}}
68 | {{result}}
69 | {{/if}}
70 |
71 |
72 | {{ip}}
73 |
74 |
75 | {{#if sess}}
76 | {{sessStr}}
77 | {{else}}
78 | –
79 | {{/if}}
80 |
81 |
82 | {{created}}
83 |
84 |
85 | {{/each}}
86 | {{else}}
87 |
88 |
89 | No events found
90 |
91 |
92 | {{/if}}
93 |
94 |
95 |
96 |
97 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
--------------------------------------------------------------------------------
/views/help.hbs:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
Account configuration
10 |
11 |
12 |
13 | Use the following configuration for your desktop email client.
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | IMAP
24 |
25 |
26 | POP3
27 |
28 |
29 | SMTP
30 |
31 |
32 |
33 |
34 |
35 |
36 | Description
37 |
38 |
39 | Access all messages and mailboxes
40 |
41 |
42 | Access INBOX
43 |
44 |
45 | Send messages
46 |
47 |
48 | {{#if user}}
49 |
50 |
51 | E-mail address
52 |
53 |
54 | {{user.address}}
55 |
56 |
57 | {{user.address}}
58 |
59 |
60 | {{user.address}}
61 |
62 |
63 | {{/if}}
64 |
65 |
66 | Server
67 |
68 |
69 | {{setup.imap.hostname}}
70 |
71 |
72 | {{setup.pop3.hostname}}
73 |
74 |
75 | {{setup.smtp.hostname}}
76 |
77 |
78 |
79 |
80 | Port
81 |
82 |
83 | {{setup.imap.port}}
84 |
85 |
86 | {{setup.pop3.port}}
87 |
88 |
89 | {{setup.smtp.port}}
90 |
91 |
92 |
93 |
94 | Security
95 |
96 |
97 | {{#if setup.imap.secure}}
98 | TLS/SSL
99 | {{else}}
100 | STARTTLS
101 | {{/if}}
102 |
103 |
104 | {{#if setup.pop3.secure}}
105 | TLS/SSL
106 | {{else}}
107 | STARTTLS
108 | {{/if}}
109 |
110 |
111 | {{#if setup.smtp.secure}}
112 | TLS/SSL
113 | {{else}}
114 | STARTTLS
115 | {{/if}}
116 |
117 |
118 |
119 |
120 | Username
121 |
122 | {{#if user}}
123 |
124 | {{user.username}}
125 |
126 |
127 | {{user.username}}
128 |
129 |
130 | {{user.username}}
131 |
132 |
133 | {{else}}
134 |
135 | Your username
136 |
137 |
138 | Your username
139 |
140 |
141 | Your username
142 |
143 | {{/if}}
144 |
145 |
146 |
147 | Password
148 |
149 | {{#if use2fa}}
150 |
151 | Two factor authentication is enabled on your account. Generate application specific passwords here to use IMAP, POP3 and SMTP.
153 |
154 | {{else}}
155 |
156 | ********
157 |
158 |
159 | ********
160 |
161 |
162 | ********
163 |
164 | {{/if}}
165 |
166 |
167 |
168 |
169 |
--------------------------------------------------------------------------------
/views/account/security/gpg.hbs:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | {{> securitymenu}}
13 |
14 |
15 |
97 |
98 |
--------------------------------------------------------------------------------
/lib/passport.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const log = require('npmlog');
4 | const util = require('util');
5 | const tokens = require('./tokens');
6 | const gravatarUrl = require('gravatar-url');
7 |
8 | const passport = require('passport');
9 | const LocalStrategy = require('passport-local').Strategy;
10 |
11 | const csrf = require('csurf');
12 | const apiClient = require('./api-client');
13 |
14 | module.exports.csrf = csrf({
15 | cookie: true
16 | });
17 |
18 | module.exports.setup = app => {
19 | app.use(passport.initialize());
20 | app.use(passport.session());
21 | };
22 |
23 | module.exports.logout = (req, res, next) => {
24 | if (req.user) {
25 | req.flash('success', util.format('%s logged out', req.user.name || req.user.username));
26 | return apiClient.users.logout(req.user, () => {
27 | req.logout(function(err) {
28 | if (err) { return next(err); }
29 | res.redirect('/');
30 | });
31 | });
32 | }
33 | res.redirect('/');
34 | };
35 |
36 | module.exports.login = (req, res, next) => {
37 | passport.authenticate('local', (err, user, info) => {
38 | if (err) {
39 | log.error('Passport', 'AUTHFAIL username=%s error=%s', req.body.username, err.message);
40 | req.flash('danger', 'Authentication error');
41 | return next(err);
42 | }
43 | if (!user) {
44 | req.flash('danger', (info && info.message) || 'Failed to authenticate user');
45 | return res.redirect('/account/login');
46 | }
47 | req.logIn(user, err => {
48 | if (err) {
49 | return next(err);
50 | }
51 |
52 | if (req.body.remember) {
53 | // Cookie expires after 30 days
54 | req.session.cookie.maxAge = 30 * 24 * 60 * 60 * 1000;
55 | } else {
56 | // Cookie expires at end of session
57 | req.session.cookie.expires = false;
58 | }
59 |
60 | // remember used username as it might differ from actual value
61 | req.session.username = req.body.username;
62 |
63 | if (req.body._2faToken && req.session.require2fa && tokens.checkToken(user.id, req.body._2faToken, tokens.TOKEN_2FA)) {
64 | req.session.require2fa = false;
65 | }
66 |
67 | if (!req.session.require2fa) {
68 | req.flash('success', util.format('Logged in as %s', user.username));
69 |
70 | // temporary value that indicates successful login and allows to use account recovery in the future
71 | req.session.successlog = {
72 | username: req.body.username,
73 | value: tokens.generateToken(req.user.id, tokens.TOKEN_RECOVERY)
74 | };
75 | }
76 |
77 | apiClient.mailboxes.list(req.user, true, (err, mailboxes) => {
78 | if (err) {
79 | req.flash('danger', err.message);
80 | res.redirect('/webmail');
81 | return;
82 | }
83 |
84 | let inbox = mailboxes.find(box => box.path === 'INBOX');
85 | req.session.inbox = inbox ? inbox.id : false;
86 |
87 | return res.redirect('/webmail');
88 | });
89 | });
90 | })(req, res, next);
91 | };
92 |
93 | module.exports.checkLogin = (req, res, next) => {
94 | if (!req.user) {
95 | return res.redirect('/account/login');
96 | }
97 | next();
98 | };
99 |
100 | passport.use(
101 | new LocalStrategy(
102 | {
103 | passReqToCallback: true
104 | },
105 | (req, username, password, done) => {
106 | req.session.regenerate(() => {
107 | apiClient.users.authenticate(username, password, req.session.id, req.ip, (err, user) => {
108 | if (err) {
109 | return done(err);
110 | }
111 |
112 | if (!user) {
113 | return done(null, false, {
114 | message: 'Incorrect username or password'
115 | });
116 | }
117 |
118 | req.session.require2fa = user.require2fa;
119 | req.session.requirePasswordChange = user.requirePasswordChange;
120 |
121 | delete user.require2fa;
122 | delete user.requirePasswordChange;
123 | done(null, user);
124 | });
125 | });
126 | }
127 | )
128 | );
129 |
130 | passport.serializeUser((user, done) => {
131 | done(null, JSON.stringify(user));
132 | });
133 |
134 | passport.deserializeUser((user, done) => {
135 | let data = null;
136 | try {
137 | data = JSON.parse(user);
138 | } catch (err) {
139 | //ignore
140 | err.resetSession = true;
141 | return done(err);
142 | }
143 |
144 | apiClient.users.get(data, (err, userData) => {
145 | if (err) {
146 | err.resetSession = true;
147 | return done(err);
148 | }
149 | if (!userData) {
150 | return done();
151 | }
152 | userData.token = data.token;
153 | apiClient.mailboxes.get(userData, 'resolve', { path: 'INBOX' }, (err, inboxData) => {
154 | if (err) {
155 | err.resetSession = true;
156 | return done(err);
157 | }
158 | userData.gravatar = gravatarUrl(userData.address || userData.username, {
159 | size: 20,
160 | // 404, mm, identicon, monsterid, wavatar, retro, blank
161 | default: 'identicon'
162 | });
163 | userData.inbox = inboxData;
164 | done(null, userData);
165 | });
166 | });
167 | });
168 |
--------------------------------------------------------------------------------
/views/account/restore.hbs:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | {{> accountmenu}}
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
Restore Deleted Messages
23 |
24 |
25 |
26 |
27 |
28 |
30 |
31 |
32 |
33 |
34 |
35 | You can restore messages that were deleted in the last 25 days by initiating a
36 | restore task. Messages deleted in the last hour may not be available for restoring
37 | yet.
38 |
39 |
40 | Restoring messages may take from few minutes to a few hours, so be patient. Creating
41 | additional restore tasks does not make the process any quicker.
42 |
43 |
44 |
55 |
56 |
57 | Start
59 | restoring
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
--------------------------------------------------------------------------------
/views/account/identities.hbs:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | {{> accountmenu}}
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
Manage identities
23 |
24 |
Here you can add and modify alias addresses for your account. Aliases act just like your main address. You can not send out emails from identities that you do not own.
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | Identity name
33 |
34 |
35 | Alias Address
36 |
37 |
38 | Created
39 |
40 |
41 |
42 |
43 |
44 |
45 | {{#each identities}}
46 |
47 |
48 | {{index}}
49 |
50 |
51 |
52 | {{#if name}}
53 | {{name}}
54 | {{else}}
55 | –
56 | {{/if}}
57 |
58 |
59 |
60 | {{#if main}}
61 | {{address}} (default)
62 | {{else}}
63 | {{address}}
64 | {{/if}}
65 |
66 |
67 |
68 | {{created}}
69 |
70 |
71 |
72 | {{#if ../canEdit}}
73 | Edit
74 | {{/if}}
75 | Delete
76 |
77 |
78 | {{/each}}
79 |
80 |
81 |
82 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
107 |
108 | Are you sure you want to permanently delete this address ?
109 |
110 |
118 |
119 |
120 |
121 |
122 |
132 |
--------------------------------------------------------------------------------
/views/account/autoreply.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Autoreply
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
Autoreply settings
18 |
19 |
20 |
21 |
22 | If enabled then an autoreply message is sent to all incoming messages. If a contact sends
23 | multiple messages then the autoreply is sent at most once in every four hours.
24 |
25 |
26 |
27 |
28 |
30 | Autoreply is {{#unless values.status}}disabled {{else}}disabled{{/unless}}
32 |
33 |
34 |
35 |
36 |
37 |
38 | Autoreply is {{#if values.status}}enabled {{else}}enabled{{/if}}
40 |
41 |
42 |
43 |
44 | Name
45 |
47 |
48 |
49 |
50 | Subject
51 |
53 |
54 |
55 |
63 |
64 |
65 | Message
66 | {{values.text}}
68 |
69 |
70 |
71 | Update
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
--------------------------------------------------------------------------------
/views/account/create.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Create new account
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
Account information
17 |
18 |
19 |
20 |
21 | Enter your account details. Account username is allowed to include latin characters only. Activated accounts can add extra identity addresses that may contain unicode characters as well.
22 |
23 |
24 |
25 |
26 |
27 |
28 | Your name
29 |
30 | {{#if errors.name}}
31 | {{errors.name}}
32 | {{/if}}
33 |
34 |
35 |
58 |
59 |
60 | Your password
61 |
62 | {{#if errors.password}}
63 | {{errors.password}}
64 | {{/if}}
65 |
66 |
67 |
68 | Repeat password
69 |
70 |
71 |
72 |
82 |
83 |
84 |
85 |
86 |
87 | {{#if recaptcha}}
88 |
92 | Create new account
93 |
94 | {{else}}
95 | Create new account
96 | {{/if}}
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 | {{#if recaptcha}}
107 |
108 |
109 |
114 | {{/if}}
115 |
116 |
141 |
--------------------------------------------------------------------------------
/app.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const config = require('wild-config');
4 | const log = require('npmlog');
5 | const express = require('express');
6 | const bodyParser = require('body-parser');
7 | const path = require('path');
8 | const favicon = require('serve-favicon');
9 | const logger = require('morgan');
10 | const cookieParser = require('cookie-parser');
11 | const session = require('express-session');
12 | const RedisStore = require('connect-redis')(session);
13 | const flash = require('connect-flash');
14 | const passport = require('./lib/passport');
15 | const httpSso = require('./lib/http-sso');
16 | const db = require('./lib/db');
17 | const multer = require('multer');
18 |
19 | const routesIndex = require('./routes/index');
20 | const routesAccount = require('./routes/account');
21 | const routesWebmail = require('./routes/webmail');
22 | const routesApi = require('./routes/api');
23 |
24 | const uploader = multer({ storage: multer.memoryStorage() });
25 |
26 | const app = express();
27 |
28 | // setup extra hbs tags
29 | require('./lib/hbs-helpers');
30 |
31 | // view engine setup
32 | app.set('views', path.join(__dirname, 'views'));
33 | app.set('view engine', 'hbs');
34 |
35 | // Handle proxies. Needed to resolve client IP
36 | if (config.www.proxy) {
37 | app.set('trust proxy', config.www.proxy);
38 | }
39 |
40 | // Do not expose software used
41 | app.disable('x-powered-by');
42 |
43 | app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
44 |
45 | app.use(
46 | logger(config.www.log, {
47 | stream: {
48 | write: message => {
49 | message = (message || '').toString();
50 | if (message) {
51 | log.info('HTTP', message.replace('\n', '').trim());
52 | }
53 | }
54 | }
55 | })
56 | );
57 |
58 | app.use(cookieParser());
59 | app.use(express.static(path.join(__dirname, 'public')));
60 |
61 | app.use(
62 | session({
63 | name: 'webmail',
64 | store: new RedisStore({
65 | client: db.redis.duplicate()
66 | }),
67 | secret: config.www.secret,
68 | saveUninitialized: false,
69 | resave: false,
70 | cookie: {
71 | secure: !!config.www.secure
72 | }
73 | })
74 | );
75 |
76 | app.use(flash());
77 |
78 | app.use(
79 | bodyParser.urlencoded({
80 | extended: true,
81 | limit: config.www.postsize
82 | })
83 | );
84 |
85 | app.use(
86 | bodyParser.text({
87 | limit: config.www.postsize
88 | })
89 | );
90 |
91 | app.use(
92 | bodyParser.json({
93 | limit: config.www.postsize
94 | })
95 | );
96 |
97 | if (config.service.sso.http.enabled) {
98 | httpSso.setup(app);
99 | } else {
100 | // default, session based auth
101 | passport.setup(app);
102 | }
103 |
104 | app.use((req, res, next) => {
105 | // make sure flash messages are available
106 | res.locals.flash = req.flash.bind(req);
107 |
108 | // userdata
109 | res.locals.user = req.user;
110 |
111 | // recaptcha
112 | if (config.recaptcha.enabled) {
113 | res.locals.recaptcha = config.recaptcha.siteKey;
114 | }
115 |
116 | // values needed to show unseen messages counter
117 | res.locals.inboxId = req.user ? req.user.inbox.id : false;
118 | res.locals.inboxUnseen = req.user ? req.user.inbox.unseen : false;
119 |
120 | res.locals.allowJoin = config.service.allowJoin;
121 | res.locals.u2fEnabled = config.u2f.enabled;
122 |
123 | res.locals.serviceName = config.name;
124 | res.locals.serviceDomain = config.service.domain;
125 |
126 | res.locals.generalNotification = config.service.generalNotification;
127 |
128 | next();
129 | });
130 |
131 | // force 2fa prompt if user is logged in and 2fa is enabled
132 | app.use((req, res, next) => {
133 | if (
134 | req.user &&
135 | req.session.require2fa &&
136 | !['/account/logout', '/account/start-u2f', '/account/check-u2f', '/account/check-totp'].includes(req.url.split('?').shift())
137 | ) {
138 | return passport.csrf(req, res, err => {
139 | if (err) {
140 | return next(err);
141 | }
142 |
143 | return res.render('account/2fa', {
144 | layout: 'layout-popup',
145 | title: 'Two factor authentication',
146 | csrfToken: req.csrfToken(),
147 | enabled2fa: req.session.require2fa,
148 | enabledTotp: req.session.require2fa ? req.session.require2fa.includes('totp') : false,
149 | enabledU2f: req.session.require2fa && req.query.u2f !== 'false' ? req.session.require2fa.includes('u2f') : false,
150 | disableU2f: req.url + (req.url.indexOf('?') >= 0 ? '&' : '?') + 'u2f=false'
151 | });
152 | });
153 | }
154 | next();
155 | });
156 |
157 | // force password change prompt if user password is reset
158 | app.use((req, res, next) => {
159 | if (req.user && req.session.requirePasswordChange && !['/account/logout', '/account/update-password'].includes(req.url.split('?').shift())) {
160 | return passport.csrf(req, res, err => {
161 | if (err) {
162 | return next(err);
163 | }
164 |
165 | return res.render('account/update-password', {
166 | layout: 'layout-popup',
167 | title: 'Change password',
168 | csrfToken: req.csrfToken()
169 | });
170 | });
171 | }
172 | next();
173 | });
174 |
175 | // setup main routes
176 | app.use('/account', passport.csrf, routesAccount);
177 |
178 | app.use(
179 | '/webmail',
180 | (req, res, next) => {
181 | if (req.url === '/send' && req.method === 'POST') {
182 | return uploader.array('attachment')(req, res, next);
183 | }
184 | next();
185 | },
186 | passport.csrf,
187 | passport.checkLogin,
188 | routesWebmail
189 | );
190 |
191 | app.use('/api', passport.csrf, passport.checkLogin, routesApi);
192 | app.use('/', passport.csrf, routesIndex);
193 |
194 | // catch 404 and forward to error handler
195 | app.use((req, res, next) => {
196 | let err = new Error('Not Found');
197 | err.status = 404;
198 | next(err);
199 | });
200 |
201 | // error handlers
202 | app.use((err, req, res, next) => {
203 | if (!err) {
204 | return next();
205 | }
206 |
207 | if (!err.resetSession) {
208 | return next(err);
209 | }
210 |
211 | req.session.regenerate(() => {
212 | return next(err);
213 | });
214 | });
215 |
216 | app.use((err, req, res, next) => {
217 | if (!err) {
218 | return next();
219 | }
220 |
221 | let message;
222 | switch (err.restCode) {
223 | case 'InvalidToken':
224 | return res.redirect('/account/login');
225 |
226 | case 'AuthFailed':
227 | message = 'Authentication failed';
228 | break;
229 |
230 | default:
231 | message = err.message;
232 | break;
233 | }
234 |
235 | if (err.code === 'InvalidToken') {
236 | return res.redirect('/account/login');
237 | }
238 |
239 | res.status(err.status || 500);
240 | res.render('error', {
241 | message,
242 | error: app.get('env') === 'development' ? err : {}
243 | });
244 | });
245 |
246 | module.exports = app;
247 |
--------------------------------------------------------------------------------
/views/account/security/asps.hbs:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | {{> securitymenu}}
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
Application specific passwords
23 |
24 |
Here are listed passwords generated for specific applications. If the password is leaked then delete it and generate a new one.
25 |
26 | Application Specific Passwords must be used for external applications if two factor authentication is enabled.
27 |
28 |
29 |
30 |
31 |
32 |
33 | #
34 |
35 |
36 | Description
37 |
38 |
39 | Created
40 |
41 |
42 | Used
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | {{#if asps}}
51 |
52 | {{#each asps}}
53 |
54 |
55 | {{index}}
56 |
57 |
58 | {{description}}
59 |
60 |
61 | {{created}}
62 |
63 |
64 | {{#if lastUse.time}}
65 | {{lastUse.time}}
66 | {{else}}
67 | never
68 | {{/if}}
69 |
70 |
71 |
72 | Delete
73 |
74 |
75 |
76 | {{/each}}
77 | {{else}}
78 |
79 |
80 | No application specific passwords generated
81 |
82 |
83 | {{/if}}
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
Create new application specific password
95 |
96 |
97 |
98 |
99 | Application description
100 |
101 | {{#if errors.description}}
102 | {{errors.description}}
103 | {{/if}}
104 |
105 |
106 |
107 | Generate password
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
128 |
129 | Are you sure you want to permanently delete Application Specific Password?
130 |
131 |
139 |
140 |
141 |
142 |
143 |
152 |
--------------------------------------------------------------------------------
/public/login-key-handler.js:
--------------------------------------------------------------------------------
1 | /* eslint-env browser */
2 | /* eslint no-bitwise: 0, no-var: 0, object-shorthand: 0, prefer-arrow-callback: 0 */
3 | // this script handle
4 |
5 | 'use strict';
6 |
7 | window.loginKeyHandler = {
8 | setup: function(usernameElm, valueElm, scope) {
9 | var that = this;
10 | var formElm = usernameElm.form;
11 | if (!formElm) {
12 | throw new Error('Form element was not found for username element');
13 | }
14 |
15 | formElm.addEventListener(
16 | 'submit',
17 | function() {
18 | var username = (usernameElm.value || '').toString();
19 | if (!username) {
20 | return;
21 | }
22 | var valueStr = that.get(username, scope);
23 | if (valueStr) {
24 | valueElm.value = valueStr;
25 | }
26 | },
27 | false
28 | );
29 | },
30 |
31 | get: function(username, scope) {
32 | scope = (scope || '').toString() || 'default';
33 | username = (username || '')
34 | .toString()
35 | .toLowerCase()
36 | .replace(/^\s+|\s+$/g, '');
37 |
38 | var data = this.loadData(scope);
39 | if (!data || !data.keys.length || !username) {
40 | // failed to access storage
41 | return false;
42 | }
43 | var entry;
44 | var hash;
45 | for (var i = 0, len = data.keys.length; i < len; i++) {
46 | entry = data.keys[i];
47 | if (!entry || !entry.hash || !entry.seed || typeof entry.seed !== 'number') {
48 | continue;
49 | }
50 | hash = this.murmurhash2_32_gc(username, entry.seed);
51 | if (hash !== entry.hash) {
52 | continue;
53 | }
54 |
55 | if (entry.expires && entry.expires < new Date().getTime()) {
56 | // found a match but it was expired
57 | data.keys.splice(i, 1);
58 | this.storeData(scope, data);
59 | return false;
60 | }
61 | // found a probable match
62 | return entry.value;
63 | }
64 | return false;
65 | },
66 |
67 | set: function(username, value, scope, expireDays) {
68 | scope = (scope || '').toString() || 'default';
69 | username = (username || '')
70 | .toString()
71 | .toLowerCase()
72 | .replace(/^\s+|\s+$/g, '');
73 |
74 | var min = 0x01000000;
75 | var max = 0xffffffff;
76 |
77 | expireDays = expireDays || 30;
78 |
79 | var data = this.loadData(scope);
80 | if (!data || !data.keys || !username) {
81 | // failed to access storage
82 | return false;
83 | }
84 |
85 | var entry;
86 | var hash;
87 | var seed;
88 | var curTime = new Date().getTime();
89 |
90 | // remove old entries
91 | for (var i = data.keys.length - 1; i >= 0; i--) {
92 | entry = data.keys[i];
93 | if (!entry || !entry.hash || !entry.seed || typeof entry.seed !== 'number') {
94 | continue;
95 | }
96 | if (entry.expires && entry.expires < curTime) {
97 | // remove expired data
98 | data.keys.splice(i, 1);
99 | continue;
100 | }
101 |
102 | hash = this.murmurhash2_32_gc(username, entry.seed);
103 | if (hash !== entry.hash) {
104 | continue;
105 | }
106 |
107 | // remove existing match
108 | data.keys.splice(i, 1);
109 | }
110 |
111 | // add new entry
112 | seed = Math.floor(Math.random() * (max - min + 1) + min);
113 | entry = {
114 | hash: this.murmurhash2_32_gc(username, seed),
115 | seed: seed,
116 | value: value,
117 | created: new Date().getTime()
118 | };
119 |
120 | entry.expires = entry.created + expireDays * 24 * 3600 * 1000;
121 |
122 | data.keys.push(entry);
123 | return this.storeData(scope, data);
124 | },
125 |
126 | loadData: function(scope) {
127 | var key = 'id:' + scope + ':data';
128 | var dataStr, data;
129 |
130 | try {
131 | dataStr = localStorage.getItem(key);
132 | } catch (E) {
133 | // failed to access local storage
134 | return false;
135 | }
136 |
137 | if (dataStr !== null) {
138 | try {
139 | data = JSON.parse(dataStr);
140 | } catch (E) {
141 | // invalid JSON
142 | }
143 | }
144 |
145 | if (!data || typeof data !== 'object' || data.scope !== scope) {
146 | data = {
147 | scope: scope,
148 | keys: []
149 | };
150 | }
151 | if (!data.keys || Object.prototype.toString.call(data.keys) !== '[object Array]') {
152 | data.keys = [];
153 | }
154 |
155 | return data;
156 | },
157 |
158 | storeData: function(scope, data) {
159 | var key = 'id:' + scope + ':data';
160 | var dataStr = JSON.stringify(data);
161 |
162 | try {
163 | localStorage.setItem(key, dataStr);
164 | } catch (E) {
165 | // failed to access local storage
166 | return false;
167 | }
168 |
169 | return data.keys.length;
170 | },
171 |
172 | /**
173 | * JS Implementation of MurmurHash2
174 | *
175 | * SOURCE: https://github.com/garycourt/murmurhash-js (MIT licensed)
176 | *
177 | * @author Gary Court
178 | * @see http://github.com/garycourt/murmurhash-js
179 | * @author Austin Appleby
180 | * @see http://sites.google.com/site/murmurhash/
181 | *
182 | * @param {string} str ASCII only
183 | * @param {number} seed Positive integer only
184 | * @return {number} 32-bit positive integer hash
185 | */
186 |
187 | murmurhash2_32_gc: function(str, seed) {
188 | var l = str.length,
189 | h = seed ^ l,
190 | i = 0,
191 | k;
192 |
193 | while (l >= 4) {
194 | k = (str.charCodeAt(i) & 0xff) | ((str.charCodeAt(++i) & 0xff) << 8) | ((str.charCodeAt(++i) & 0xff) << 16) | ((str.charCodeAt(++i) & 0xff) << 24);
195 |
196 | k = (k & 0xffff) * 0x5bd1e995 + ((((k >>> 16) * 0x5bd1e995) & 0xffff) << 16);
197 | k ^= k >>> 24;
198 | k = (k & 0xffff) * 0x5bd1e995 + ((((k >>> 16) * 0x5bd1e995) & 0xffff) << 16);
199 |
200 | h = ((h & 0xffff) * 0x5bd1e995 + ((((h >>> 16) * 0x5bd1e995) & 0xffff) << 16)) ^ k;
201 |
202 | l -= 4;
203 | ++i;
204 | }
205 |
206 | switch (l) {
207 | case 3:
208 | h ^= (str.charCodeAt(i + 2) & 0xff) << 16;
209 | /* falls through */
210 | case 2:
211 | h ^= (str.charCodeAt(i + 1) & 0xff) << 8;
212 | /* falls through */
213 | case 1:
214 | h ^= str.charCodeAt(i) & 0xff;
215 | h = (h & 0xffff) * 0x5bd1e995 + ((((h >>> 16) * 0x5bd1e995) & 0xffff) << 16);
216 | }
217 |
218 | h ^= h >>> 13;
219 | h = (h & 0xffff) * 0x5bd1e995 + ((((h >>> 16) * 0x5bd1e995) & 0xffff) << 16);
220 | h ^= h >>> 15;
221 |
222 | return h >>> 0;
223 | }
224 | };
225 |
--------------------------------------------------------------------------------
/views/account/security/2fa.hbs:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | {{> securitymenu}}
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
Two factor authentication
23 |
24 |
25 |
26 | If two-factor authentication is enabled then you will be required to enter a code from an authenticator app when logging in.
27 | TOTP compatible authenticator app like Google Authenticator is needed to use two-factor authentication.
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | External applications can not access IMAP, POP3 ja SMTP using the account password if two-factor authentication is enabled. Application specific passwords must be generated instead for these applications.
38 |
39 |
40 |
41 |
42 |
43 |
44 | {{#if enabled2fa}}
45 | Two factor authentication is Enabled
46 | {{else}}
47 | Two factor authentication is Disabled
48 | {{/if}}
49 |
50 |
51 | {{#if enabled2fa}}
52 | Disable
53 | {{else}}
54 |
55 |
56 | Enable
57 |
58 | {{/if}}
59 |
60 |
61 |
62 | {{#if enabled2fa}}
63 |
64 |
65 | {{#if enabledU2f}}
66 | U2F security key is Enabled
67 | {{else}}
68 | U2F security key is Disabled
69 | {{/if}}
70 |
71 |
72 | {{#if enabledU2f}}
73 | Disable
74 | {{else}}
75 |
76 |
77 | Enable
78 |
79 | {{/if}}
80 |
81 |
82 | {{/if}}
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
98 |
99 | Are you sure you want to disable two factor authentication?
100 |
101 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
119 |
120 | Are you sure you want to revoke U2F security key?
121 |
122 |
129 |
130 |
131 |
132 |
--------------------------------------------------------------------------------
/public/css/wildduck.css:
--------------------------------------------------------------------------------
1 | /* Sticky footer styles
2 | -------------------------------------------------- */
3 |
4 | html {
5 | position: relative;
6 | min-height: 100%;
7 | }
8 |
9 | body {
10 | /* Margin bottom by footer height */
11 | margin-bottom: 60px;
12 | }
13 |
14 | .footer {
15 | position: absolute;
16 | bottom: 0;
17 | width: 100%;
18 | /* Set the fixed height of the footer here */
19 | height: 60px;
20 | background-color: #f5f5f5;
21 | }
22 |
23 | /*
24 | .navbar-default, .panel-default>.panel-heading, .footer {
25 | background: url('/images/seigaiha.png')
26 | }
27 | */
28 |
29 | .footer .text-muted {
30 | margin: 20px 0;
31 | }
32 |
33 | input.lowercase {
34 | text-transform: lowercase;
35 | }
36 |
37 | [v-cloak] {
38 | display: none;
39 | }
40 |
41 | /*
42 | * Global add-ons
43 | */
44 |
45 | .sub-header {
46 | padding-bottom: 10px;
47 | border-bottom: 1px solid #eee;
48 | }
49 |
50 | /*
51 | * Top navigation
52 | * Hide default border to remove 1px line.
53 | */
54 | .navbar-fixed-top {
55 | border: 0;
56 | }
57 |
58 | /*
59 | * Sidebar
60 | */
61 |
62 | /* Hide for mobile, show later */
63 | .sidebar {
64 | width: 250px;
65 | padding: 20px;
66 | background-color: #f5f5f5;
67 | border-right: 1px solid #eee;
68 | }
69 |
70 | /* Sidebar navigation */
71 | .nav-sidebar {
72 | margin-right: -21px; /* 20px padding + 1px border */
73 | margin-bottom: 0px;
74 | margin-left: -20px;
75 | }
76 | .nav-sidebar > li > a {
77 | padding: 3px 20px;
78 | }
79 | .nav-sidebar > .active > a,
80 | .nav-sidebar > .active > a:hover,
81 | .nav-sidebar > .active > a:focus {
82 | color: #fff;
83 | background-color: #428bca;
84 | }
85 |
86 | /*
87 | * Main content
88 | */
89 |
90 | .main {
91 | padding-right: 40px;
92 | padding-left: 40px;
93 | }
94 |
95 | .main .page-header {
96 | margin-top: 0;
97 | }
98 |
99 | /*
100 | * Placeholder dashboard ideas
101 | */
102 |
103 | .placeholders {
104 | margin-bottom: 30px;
105 | text-align: center;
106 | }
107 | .placeholders h4 {
108 | margin-bottom: 0;
109 | }
110 | .placeholder {
111 | margin-bottom: 20px;
112 | }
113 | .placeholder img {
114 | display: inline-block;
115 | border-radius: 50%;
116 | }
117 |
118 | .iframe-box {
119 | width: 100%;
120 | margin-top: 10px;
121 | }
122 |
123 | .iframe-box iframe {
124 | margin: 0;
125 | padding: 0;
126 | border: none;
127 | width: 100%;
128 | height: 100%;
129 | background: white;
130 | }
131 |
132 | .form-footer {
133 | margin-top: 10px;
134 | padding-top: 5px;
135 | border-top: 1px solid #ddd;
136 | }
137 |
138 | .webmail-container {
139 | display: flex;
140 | margin-top: -20px;
141 | }
142 |
143 | .sidebar {
144 | width: 250px;
145 | }
146 |
147 | .sidebar-logo a {
148 | display: block;
149 | margin-left: -20px;
150 | margin-right: -21px;
151 | }
152 |
153 | .sidebar-logo a {
154 | text-decoration: none;
155 | }
156 |
157 | .sidebar-logo img {
158 | border: 0px;
159 | display: block;
160 | margin: 0px auto;
161 | }
162 |
163 | .webmail-main {
164 | flex-grow: 1;
165 | padding: 0 40px 0 20px;
166 | }
167 |
168 | .messagelist {
169 | table-layout: fixed;
170 | width: 100%;
171 | border-spacing: 0;
172 | border-collapse: collapse;
173 | }
174 |
175 | .messagerow {
176 | width: 100%;
177 | }
178 |
179 | .messagerow td {
180 | border-bottom: 1px #e5e5e5 solid;
181 | background-color: #f5f5f5;
182 | }
183 |
184 | tr.messagerow:hover td {
185 | background-color: #e7e7e7;
186 | }
187 |
188 | .messagerow-link {
189 | display: block;
190 | text-decoration: none;
191 | color: #333;
192 | border: 0;
193 | line-height: 3rem;
194 | }
195 |
196 | .messagerow-link:hover {
197 | text-decoration: none;
198 | }
199 |
200 | .messagerow-spacer-col {
201 | width: 10px;
202 | }
203 |
204 | .messagerow-checkbox-col {
205 | width: 20px;
206 | }
207 |
208 | .messagerow-star-col {
209 | width: 20px;
210 | }
211 |
212 | .messagerow-from-col {
213 | width: 200px;
214 | }
215 |
216 | .messagerow-date-col {
217 | width: 75px;
218 | }
219 |
220 | .messagerow-info-col {
221 | width: 40px;
222 | }
223 |
224 | .messagerow-subject-col {
225 | }
226 |
227 | .messagerow-spacer {
228 | empty-cells: show;
229 | }
230 |
231 | .messagerow-star,
232 | .messagerow-checkbox {
233 | line-height: 3rem;
234 | }
235 |
236 | .messagerow-star a {
237 | text-align: center;
238 | }
239 |
240 | .messagerow-date a {
241 | text-align: right;
242 | }
243 |
244 | .messagerow-info a {
245 | text-align: right;
246 | }
247 |
248 | .messagerow-subject,
249 | .messagerow-from {
250 | }
251 |
252 | .messagerow-subject a,
253 | .messagerow-date a,
254 | .messagerow-from a {
255 | overflow: hidden;
256 | white-space: nowrap;
257 | text-overflow: ellipsis;
258 | text-decoration: none !important;
259 | }
260 |
261 | a.message-star.flagged {
262 | color: #048ba8;
263 | }
264 |
265 | a.message-star.unflagged {
266 | color: #a5a5a5;
267 | }
268 |
269 | tr.message-seen td a {
270 | font-weight: normal;
271 | }
272 |
273 | tr.message-unseen td a {
274 | font-weight: bold;
275 | }
276 |
277 | .bulk-move-path {
278 | font-weight: bold;
279 | }
280 |
281 | table.limited {
282 | table-layout: fixed;
283 | width: 100%;
284 | border-spacing: 0;
285 | border-collapse: collapse;
286 | }
287 |
288 | .message-subject-line {
289 | overflow: hidden;
290 | white-space: nowrap;
291 | text-overflow: ellipsis;
292 | }
293 |
294 | .note-editable blockquote {
295 | padding: 7px 10px;
296 | margin: 0 0 10px;
297 | font-size: inherit;
298 | border-left: 2px solid #eeeeee;
299 | }
300 |
301 | .toolbar-container {
302 | display: flex;
303 | flex-wrap: wrap-reverse;
304 | }
305 |
306 | .toolbar-main {
307 | flex-grow: 1;
308 | }
309 |
310 | .toolbar-search {
311 | flex-grow: 1;
312 | margin-bottom: 10px;
313 | }
314 |
315 | @media screen and (max-width: 767px) {
316 | .webmail-container {
317 | flex-direction: column-reverse;
318 | }
319 | .sidebar {
320 | width: inherit;
321 | }
322 |
323 | .messagerow-spacer-col {
324 | display: none;
325 | }
326 | .messagerow-spacer {
327 | display: none;
328 | }
329 |
330 | .messagerow-checkbox-col {
331 | width: 20px;
332 | }
333 |
334 | .messagerow-star-col {
335 | width: 20px;
336 | }
337 |
338 | .messagerow-from-col {
339 | width: 100px;
340 | }
341 |
342 | .messagerow-date-col {
343 | width: 75px;
344 | }
345 |
346 | .messagerow-info-col {
347 | display: none;
348 | }
349 | .messagerow-info {
350 | display: none;
351 | }
352 |
353 | .toolbar-main .btn {
354 | margin-bottom: 10px;
355 | margin-right: 5px;
356 | }
357 | }
358 |
359 | tr.identity-main td {
360 | font-weight: bold;
361 | }
362 |
363 | .navbar-nav img.profile-image {
364 | display: inline-block;
365 | border: 0;
366 | }
367 |
368 | .alert {
369 | padding-top: 5px;
370 | padding-bottom: 5px;
371 | display: inline-block;
372 | margin: 0px auto;
373 | }
374 |
375 | .flash-messages {
376 | position: absolute;
377 | display: flex;
378 | justify-content: center;
379 | margin: 0px auto;
380 | width: 100%;
381 | z-index: 999;
382 | margin-top: -5px;
383 | }
384 |
385 | .grecaptcha-badge {
386 | z-index: 999;
387 | }
388 |
389 | #message-content.mailvelope {
390 | height: 500px;
391 | }
392 |
393 | .bimi {
394 | float: right;
395 | height: 64px;
396 | width: 64px;
397 | background-size: cover;
398 | text-decoration: none;
399 | color: white;
400 | margin: 0px;
401 | border-radius: 24px;
402 | border: 1px solid #158cba;
403 | background-color: #fafafa;
404 | }
--------------------------------------------------------------------------------