├── 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 |

{{error.status}} Error

2 | 3 |

{{message}}

4 | 5 | {{#if error.stack}} 6 |
{{error.stack}}
7 | {{/if}} 8 | -------------------------------------------------------------------------------- /public/bootstrap-3.3.7/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nodemailer/wildduck-webmail/HEAD/public/bootstrap-3.3.7/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /public/bootstrap-3.3.7/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nodemailer/wildduck-webmail/HEAD/public/bootstrap-3.3.7/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /public/bootstrap-3.3.7/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nodemailer/wildduck-webmail/HEAD/public/bootstrap-3.3.7/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "public/components/", 3 | "timeout": 120000, 4 | "registry": { 5 | "search": [ 6 | "https://registry.bower.io" 7 | ] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /public/bootstrap-3.3.7/fonts/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nodemailer/wildduck-webmail/HEAD/public/bootstrap-3.3.7/fonts/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 160, 3 | tabWidth: 4, 4 | singleQuote: true, 5 | endOfLine: 'lf', 6 | trailingComma: 'none', 7 | arrowParens: 'avoid' 8 | }; 9 | -------------------------------------------------------------------------------- /lib/db.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const config = require('wild-config'); 4 | const Redis = require('ioredis'); 5 | 6 | module.exports.redis = false; 7 | 8 | module.exports.connect = (callback) => { 9 | module.exports.redis = new Redis(config.dbs.redis); 10 | return callback(); 11 | }; 12 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "indent": 0, 4 | "no-await-in-loop": 0, 5 | "require-atomic-updates": 0, 6 | "no-prototype-builtins": 0 7 | }, 8 | "extends": ["nodemailer", "prettier"], 9 | "parserOptions": { 10 | "ecmaVersion": 2018 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /public/css/mail.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | font-size: 13px; 4 | color: #000; 5 | margin: 0px; 6 | padding: 10px 0; 7 | } 8 | 9 | blockquote, 10 | table th, 11 | table td { 12 | font-size: 13px; 13 | } 14 | 15 | blockquote { 16 | padding: 7px 10px; 17 | margin: 0 0 10px; 18 | font-size: inherit; 19 | border-left: 2px solid #eeeeee; 20 | } 21 | -------------------------------------------------------------------------------- /views/tos.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Terms and Conditions ("Terms")

4 |
5 |
6 | 7 |
8 |
9 |

TOS

10 |
11 | {{>tos}} 12 |
13 |
14 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function(grunt) { 4 | // Project configuration. 5 | grunt.initConfig({ 6 | eslint: { 7 | all: ['lib/**/*.js', 'routes/**/*.js', 'app.js', 'server.js', 'Gruntfile.js'] 8 | } 9 | }); 10 | 11 | // Load the plugin(s) 12 | grunt.loadNpmTasks('grunt-eslint'); 13 | 14 | // Tasks 15 | grunt.registerTask('default', ['eslint']); 16 | }; 17 | -------------------------------------------------------------------------------- /views/partials/accountmenu.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /views/partials/searchfield.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 | 6 | 7 |
8 |
9 | -------------------------------------------------------------------------------- /public/bootstrap-3.3.7/js/npm.js: -------------------------------------------------------------------------------- 1 | // This file is autogenerated via the `commonjs` Grunt task. You can require() this file in a CommonJS environment. 2 | require('../../js/transition.js') 3 | require('../../js/alert.js') 4 | require('../../js/button.js') 5 | require('../../js/carousel.js') 6 | require('../../js/collapse.js') 7 | require('../../js/dropdown.js') 8 | require('../../js/modal.js') 9 | require('../../js/tooltip.js') 10 | require('../../js/popover.js') 11 | require('../../js/scrollspy.js') 12 | require('../../js/tab.js') 13 | require('../../js/affix.js') -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const config = require('wild-config'); 4 | const express = require('express'); 5 | const router = new express.Router(); 6 | 7 | /* GET home page. */ 8 | router.get('/', (req, res) => { 9 | res.render('index', {}); 10 | }); 11 | 12 | router.get('/help', (req, res) => { 13 | res.render('help', { 14 | activeHelp: true, 15 | setup: config.setup, 16 | use2fa: res.locals.user && res.locals.user.enabled2fa && res.locals.user.enabled2fa.length 17 | }); 18 | }); 19 | 20 | router.get('/tos', (req, res) => { 21 | res.render('tos', { 22 | activeCreate: true 23 | }); 24 | }); 25 | 26 | module.exports = router; 27 | -------------------------------------------------------------------------------- /views/account/security.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Security

4 |
5 |
6 | 7 |
8 |
9 | 10 | 11 | 14 | 15 |
16 |
17 | 18 |

 

19 | 20 |

21 | Future feature 22 |

23 |
24 |
25 |
26 |
27 | -------------------------------------------------------------------------------- /views/account/filters/create.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Create filter

4 |
5 |
6 | 7 | 8 |
9 | 10 | 11 | {{> filter}} 12 | 13 |
14 | 15 | Cancel 16 |
17 | 18 |
19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # --- 2 | from alpine:3.18 as builder 3 | 4 | ARG UID=1000 5 | ENV APP_PATH /app 6 | WORKDIR ${APP_PATH} 7 | COPY . . 8 | RUN adduser -D -g '' builder -u ${UID} 9 | RUN chown -R builder . 10 | 11 | RUN apk add --no-cache nodejs 12 | RUN apk add --no-cache openssl 13 | RUN apk add --no-cache --virtual build-deps git python3 npm make g++ 14 | 15 | USER builder 16 | RUN npm install && npm run bowerdeps 17 | 18 | # --- 19 | from alpine:3.18 as app 20 | RUN apk add --no-cache nodejs 21 | ENV APP_PATH /app 22 | WORKDIR ${APP_PATH} 23 | COPY --from=builder ${APP_PATH}/ ${APP_PATH}/ 24 | COPY --from=builder ${APP_PATH}/config/default.toml /etc/wildduck/www.toml 25 | ENTRYPOINT ["node", "server.js"] 26 | CMD ["--config=/etc/wildduck/www.toml"] 27 | -------------------------------------------------------------------------------- /views/partials/securitymenu.hbs: -------------------------------------------------------------------------------- 1 | {{#unless ssoEnabled}} 2 | 4 | 6 | {{/unless}} 7 | 8 | 10 | 11 | -------------------------------------------------------------------------------- /views/account/filters/edit.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Updated filter

4 |
5 |
6 | 7 |
8 | 9 | 10 | 11 | {{> filter}} 12 | 13 |
14 | 15 | Cancel 16 |
17 | 18 |
19 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wildduck-webmail", 3 | "description": "Webmail for WildDuck", 4 | "main": "index.js", 5 | "authors": ["Andris Reinman"], 6 | "license": "EUPL-1.1", 7 | "homepage": "https://wildduck.email", 8 | "private": true, 9 | "ignore": ["**/.*", "node_modules", "bower_components", "public/components/", "test", "tests"], 10 | "dependencies": { 11 | "DOMPurify": "dompurify#^1.0.2", 12 | "jquery": "*", 13 | "fetch": "2.0.4", 14 | "moment": "*", 15 | "handlebars": "*", 16 | "bootstrap-daterangepicker": "*", 17 | "summernote": "0.8.20", 18 | "event-source-polyfill": "*", 19 | "underscore": "*", 20 | "promise-polyfill": "^7.0.0", 21 | "favico.js": "^0.3.10" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /views/partials/header.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | {{serviceName}} 13 | {{#if title}} | {{title}}{{/if}} 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /views/layout.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{>header}} 6 | 7 | 8 | 9 | 10 | {{>navbar}} 11 |
12 | {{flash_messages}} 13 |
14 | 15 | 16 | 17 |
18 | 19 | {{#if generalNotification}} 20 |
21 |
{{{generalNotification}}}
22 |
23 | {{/if}} 24 | 25 | {{{body}}} 26 | 27 |
28 | 29 | 35 | 36 | {{> scripts}} 37 | 38 | 39 | -------------------------------------------------------------------------------- /public/css/popup.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding-top: 40px; 3 | padding-bottom: 40px; 4 | background-color: #eee; 5 | } 6 | 7 | .form-popup { 8 | max-width: 330px; 9 | padding: 15px; 10 | margin: 0 auto; 11 | } 12 | 13 | .form-popup .form-popup-heading, .form-popup .checkbox { 14 | margin-bottom: 10px; 15 | } 16 | 17 | .form-popup .checkbox { 18 | font-weight: normal; 19 | } 20 | 21 | .form-popup .form-control { 22 | position: relative; 23 | height: auto; 24 | -webkit-box-sizing: border-box; 25 | -moz-box-sizing: border-box; 26 | box-sizing: border-box; 27 | padding: 10px; 28 | font-size: 16px; 29 | } 30 | 31 | .form-popup .form-control:focus { 32 | z-index: 2; 33 | } 34 | 35 | .form-popup input[type="email"] { 36 | margin-bottom: -1px; 37 | border-bottom-right-radius: 0; 38 | border-bottom-left-radius: 0; 39 | } 40 | 41 | .form-popup input[type="password"] { 42 | margin-bottom: 10px; 43 | border-top-left-radius: 0; 44 | border-top-right-radius: 0; 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/docker-latest.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish a Docker image for master branch 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | docker: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v3 12 | 13 | - name: Set up QEMU 14 | uses: docker/setup-qemu-action@v2 15 | with: 16 | platforms: 'arm64,arm' 17 | 18 | - name: Set up Docker Buildx 19 | id: buildx 20 | uses: docker/setup-buildx-action@v2 21 | with: 22 | platforms: linux/arm64,linux/amd64,linux/arm/v7 23 | 24 | - name: Login to Docker Hub 25 | uses: docker/login-action@v2 26 | with: 27 | username: ${{ secrets.DOCKERHUB_USERNAME }} 28 | password: ${{ secrets.DOCKERHUB_TOKEN }} 29 | 30 | - name: Build and push 31 | uses: docker/build-push-action@v4 32 | with: 33 | context: . 34 | platforms: ${{ steps.buildx.outputs.platforms }} 35 | push: true 36 | tags: ${{ github.repository }}:latest 37 | -------------------------------------------------------------------------------- /public/bootstrap-tagsinput-2.3.2/bootstrap-tagsinput.less: -------------------------------------------------------------------------------- 1 | .bootstrap-tagsinput { 2 | background-color: #fff; 3 | border: 1px solid #ccc; 4 | box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); 5 | display: inline-block; 6 | padding: 4px 6px; 7 | margin-bottom: 10px; 8 | color: #555; 9 | vertical-align: middle; 10 | border-radius: 4px; 11 | max-width: 100%; 12 | line-height: 22px; 13 | cursor: text; 14 | 15 | input { 16 | border: none; 17 | box-shadow: none; 18 | outline: none; 19 | background-color: transparent; 20 | padding: 0; 21 | margin: 0; 22 | width: auto !important; 23 | max-width: inherit; 24 | 25 | &:focus { 26 | border: none; 27 | box-shadow: none; 28 | } 29 | } 30 | 31 | .tag { 32 | margin-right: 2px; 33 | color: white; 34 | 35 | [data-role="remove"] { 36 | margin-left:8px; 37 | cursor:pointer; 38 | &:after{ 39 | content: "x"; 40 | padding:0px 2px; 41 | } 42 | &:hover { 43 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); 44 | &:active { 45 | box-shadow: inset 0 3px 5px rgba(0,0,0,0.125); 46 | } 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /views/account/security/asp.hbs: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
5 | 6 |
7 |
8 |

Application specific password

9 |
10 |
11 | 12 |

13 | Use the generated password in external application for IMAP, POP3 or SMTP 14 |

15 | 16 |

17 | {{description}} 18 |

19 | 20 |

21 | {{passwordFormatted}} 22 |

23 | 24 |

25 | For OSX and iOS you can download configuration profile to auto-configure your email application 26 |

27 | 28 |

29 |

30 | OSX / iOS 31 |
32 | Go back 33 |

34 |
35 |
36 | -------------------------------------------------------------------------------- /public/bootstrap-tagsinput-2.3.2/bootstrap-tagsinput-angular.min.js: -------------------------------------------------------------------------------- 1 | /* 2 | * bootstrap-tagsinput v0.6.1 by Tim Schlechter 3 | * 4 | */ 5 | 6 | angular.module("bootstrap-tagsinput",[]).directive("bootstrapTagsinput",[function(){function a(a,b){return b?angular.isFunction(a.$parent[b])?a.$parent[b]:function(a){return a[b]}:void 0}return{restrict:"EA",scope:{model:"=ngModel"},template:"",replace:!1,link:function(b,c,d){$(function(){angular.isArray(b.model)||(b.model=[]);var e=$("select",c),f=d.typeaheadSource?d.typeaheadSource.split("."):null,g=f?f.length>1?b.$parent[f[0]][f[1]]:b.$parent[f[0]]:null;e.tagsinput(b.$parent[d.options||""]||{typeahead:{source:angular.isFunction(g)?g:null},itemValue:a(b,d.itemvalue),itemText:a(b,d.itemtext),confirmKeys:a(b,d.confirmkeys)?JSON.parse(d.confirmkeys):[13],tagClass:angular.isFunction(b.$parent[d.tagclass])?b.$parent[d.tagclass]:function(a){return d.tagclass}});for(var h=0;h 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 | 20 | 21 | 22 |
23 | 24 |
25 |
26 | 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 | 22 | 23 | 26 | 27 |
28 | 29 |

30 | Initializing... 31 |

32 | 33 |
34 | Cancel 35 |
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 |

Create folder

3 | 4 |
5 | 6 | 7 | {{> mailbox}} 8 | 9 |
10 | 11 |
12 | 13 |
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 | ''; 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 |
41 | {{{body}}} 42 |
43 | 44 |
45 | 46 | 47 | 53 | 54 | {{> scripts}} 55 | 56 | 57 | -------------------------------------------------------------------------------- /views/account/identities/create.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Account

4 |
5 |
6 | 7 |
8 |
9 | 10 | 11 | 14 | 15 |
16 |
17 | 18 |

 

19 | 20 |
21 | 22 | 23 | 24 |
25 |
26 |

Identity information

27 |
28 |
29 | 30 |
31 |
32 | {{> identity}} 33 |
34 |
35 | 36 |
37 | 38 | Cancel 39 |
40 |
41 |
42 |
43 |
44 |
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 | ![](https://raw.githubusercontent.com/nodemailer/wildduck-webmail/master/public/demo/img01.png) 32 | 33 | ![](https://raw.githubusercontent.com/nodemailer/wildduck-webmail/master/public/demo/img02.png) 34 | 35 | ![](https://raw.githubusercontent.com/nodemailer/wildduck-webmail/master/public/demo/img03.png) 36 | 37 | ![](https://raw.githubusercontent.com/nodemailer/wildduck-webmail/master/public/demo/img04.png) 38 | 39 | ![](https://raw.githubusercontent.com/nodemailer/wildduck-webmail/master/public/demo/img05.png) 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 | ![](https://cldup.com/GhuFvNx5Js.png) 48 | 49 | **Sender did not use TLS** 50 | 51 | ![](https://cldup.com/pUgzVxpMFW.png) 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 |
2 |
3 |

Account

4 |
5 |
6 | 7 |
8 |
9 | 10 | 11 | 14 | 15 |
16 |
17 | 18 |

 

19 |
20 | 21 | 22 | 23 | 24 |
25 |
26 |

Identity information

27 |
28 |
29 | 30 |
31 |
32 | {{> identity}} 33 |
34 |
35 | 36 |
37 | 38 | Cancel 39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | -------------------------------------------------------------------------------- /views/account/update-password.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Change Password

4 |
5 |
6 | 7 |
8 | 9 | 10 |

11 | Your password needs to be changed. Enter your new account password below 12 |

13 | 14 |
15 | 16 | 17 | {{#if errors.password}} 18 | {{errors.password}} 19 | {{/if}} 20 |
21 | 22 |
23 | 24 | 25 | {{#if errors.password2}} 26 | {{errors.password2}} 27 | {{/if}} 28 |
29 | 30 |
31 |
32 | 33 |
34 | Cancel 35 |
36 | 37 |
38 | 39 |
40 | 41 |
42 | 43 |
44 | -------------------------------------------------------------------------------- /views/partials/mailbox.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

Mailbox settings

5 |
6 |
7 | 8 | {{#if isInbox}} 9 | 10 |
11 | 12 | 13 | INBOX folder can not be modified 14 |
15 | 16 | {{else}} 17 | 18 |
19 | 20 | 28 | 29 | {{#if errors.parent}} 30 | {{errors.parent}} 31 | {{/if}} 32 |
33 | 34 |
35 | 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 | 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 |
12 | 13 |
14 | 15 | 16 | 19 | 24 | 25 |
26 | 27 | {{#if errors.address}} 28 | {{errors.address}} 29 | {{else}} 30 | Unicode characters are allowed in alias addresses. 31 | {{/if}} 32 |
33 | 34 | {{#unless isMain}} 35 |
36 |
37 | 40 |
41 |
42 | {{/unless}} 43 | 44 | 69 | -------------------------------------------------------------------------------- /views/account/login.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Log in

4 |
5 |
6 | 7 |
8 |
9 | 10 |
11 | 12 | 13 | 14 |
15 |
16 |

Account information

17 |
18 |
19 | 20 |
21 |
22 | 23 |
24 | 25 | 26 | {{#if errors.username}} 27 | {{errors.username}}{{#if errors.username_action}} – {{errors.username_action.title}}{{/if}} 28 | {{/if}} 29 |
30 | 31 |
32 | 33 | 34 | {{#if errors.password}} 35 | {{errors.password}} 36 | {{/if}} 37 |
38 |
39 |
40 | 41 |
42 |
43 | 46 |
47 |
48 | 49 | 50 |
51 | 52 |
53 |
54 | 55 |
56 |
57 | 58 |
59 |
60 | 61 | 62 | 69 | -------------------------------------------------------------------------------- /views/webmail/mailbox.hbs: -------------------------------------------------------------------------------- 1 | 2 |

{{mailbox.name}}

3 | 4 |
5 | 6 | 7 | {{> mailbox}} 8 | 9 | {{#unless isInbox}} 10 |
11 | {{#unless isSpecial}} 12 |
13 | 14 |
15 | {{/unless}} 16 | 17 |
18 | {{/unless}} 19 | 20 |
21 | 22 | 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 | 20 | 21 | 24 | 25 |
26 | 27 |

28 | Initializing... 29 |

30 | 31 |
32 | 35 | Cancel 36 |
37 | 38 |
39 | 40 |
41 | 42 |
43 | 44 |

45 | Open your authentication app and enter the code to log in 46 |

47 | 48 |
49 | 50 | 51 | 52 |
53 | 54 |
55 |
56 | 57 |
58 | Cancel 59 |
60 | 61 |
62 | 63 |
64 | 65 |
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 |
2 |
3 |

Security

4 |
5 |
6 | 7 |
8 |
9 | 10 | 11 | 14 | 15 |
16 |
17 | 18 |

 

19 | 20 |
21 | 22 | 23 |
24 | 25 |
26 |
27 |

Change Password

28 |
29 |
30 | 31 |

32 | Change your account password here 33 |

34 | 35 |
36 | 37 | 38 | {{#if errors.existingPassword}} 39 | {{errors.existingPassword}} 40 | {{/if}} 41 |
42 | 43 |
44 | 45 | 46 | {{#if errors.password}} 47 | {{errors.password}} 48 | {{/if}} 49 |
50 | 51 |
52 | 53 | 54 |
55 | 56 |
57 | 58 |
59 | 60 |
61 |
62 |
63 |
64 |
65 |
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 |
2 |
3 |

Filters

4 |
5 |
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 | 24 | 34 | 35 | {{/each}} 36 | {{else}} 37 | 38 | 41 | 42 | {{/if}} 43 | 44 |
22 | {{index}} 23 | 25 |
26 | Edit 27 | 28 | 29 |
30 |
31 | Query: {{query}}
Action: {{action}} 32 |
33 |
39 | There are no filters created 40 |
45 | 46 |
47 |
48 | Add new filter 49 |
50 | 51 |
52 |
53 | 54 |
55 |
56 | 57 | 58 | 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 |

2 |
3 | 4 | 5 | 8 | 9 |
6 | {{messageData.subject}} 7 |
10 |
11 | 12 |
13 | 14 |
15 |

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 | -------------------------------------------------------------------------------- /views/account/profile.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Account

4 |
5 |
6 | 7 |
8 |
9 | 10 | 11 | 14 | 15 |
16 |
17 | 18 |

 

19 | 20 | 21 |
22 | 23 | 24 |
25 |
26 |
27 |

General

28 |
29 |
30 | 31 |
32 | 33 |
34 |

{{values.username}}

35 |
36 |
37 | 38 |
39 | 40 | 41 | {{#if errors.name}} 42 | {{errors.name}} 43 | {{/if}} 44 |
45 | 46 |
47 | 48 | 58 | 59 | {{#if errors.spamLevel}} 60 | {{errors.spamLevel}} 61 | {{/if}} 62 |
63 | 64 |
65 |
66 | 67 |
68 |
69 |

Message forwarding

70 |
71 |
72 | 73 |

74 | Leave the following fields blank if you do not wish to forward all incoming emails 75 |

76 | 77 |
78 | 79 | 80 | {{#if errors.targets}} 81 | {{errors.targets}} 82 | {{/if}} 83 | Use comma separated list of addresses for multiple recipients 84 |
85 |
86 |
87 | 88 |
89 | 90 |
91 | 92 |
93 | 94 |
95 |
96 |
97 |
98 |
99 | -------------------------------------------------------------------------------- /views/account/index.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Account

4 |
5 |
6 | 7 |
8 |
9 | 10 | 11 | 14 | 15 |
16 |
17 | 18 |

 

19 | 20 |
21 |
22 | 23 |
24 | 25 |
26 |

27 | {{address}} 28 |

29 |
30 |
31 | 32 |
33 |
34 | 35 |
36 |
37 | 38 |
39 | 40 |
41 |

42 | Used {{storageUsed}} of {{quota}} 43 |

44 |
45 |
46 | 47 |
48 |
49 | {{storageOverview}}% 50 |
51 |
52 | 53 |
54 | 55 |
56 |

57 | Sent {{recipientsSent}} messages, daily allowed quota {{recipients}} messages 58 |

59 |
60 |
61 | 62 |
63 |
64 | {{recipientsOverview}}% 65 |
66 |
67 | 68 |
69 | 70 |
71 |

72 | Forwarded {{forwardsSent}} messages, daily allowed quota {{forwards}} messages 73 |

74 |
75 |
76 | 77 |
78 |
79 | {{forwardsOverview}}% 80 |
81 |
82 |
83 |
84 | 85 |
86 |
87 |
88 |
89 | 90 | 91 | 104 | -------------------------------------------------------------------------------- /views/account/security/events.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Security

4 |
5 |
6 | 7 |
8 |
9 | 10 | 11 | 14 | 15 |
16 |
17 | 18 |

 

19 | 20 | 21 | 22 | 23 | 26 | 29 | 32 | 35 | 38 | 41 | 42 | 43 | 44 | {{#if results}} 45 | 46 | {{#each results}} 47 | 48 | 51 | 64 | 71 | 74 | 81 | 84 | 85 | {{/each}} 86 | {{else}} 87 | 88 | 91 | 92 | {{/if}} 93 | 94 |
24 | Environment 25 | 27 | Action 28 | 30 | Result 31 | 33 | IP 34 | 36 | Session 37 | 39 | Time 40 |
49 | {{protocol}} 50 | 52 | 53 | 54 | {{#if asp}} 55 |
56 | {{asp.name}} 57 |
58 | {{/if}} 59 | 60 | {{action}} 61 | 62 | ({{events}}) 63 |
65 | {{#if label}} 66 | {{result}} 67 | {{else}} 68 | {{result}} 69 | {{/if}} 70 | 72 | {{ip}} 73 | 75 | {{#if sess}} 76 | {{sessStr}} 77 | {{else}} 78 | – 79 | {{/if}} 80 | 82 | {{created}} 83 |
89 | No events found 90 |
95 | 96 | 111 | 112 |
113 |
114 |
115 |
116 | -------------------------------------------------------------------------------- /views/help.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Help

4 |
5 |
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 | 22 | 25 | 28 | 31 | 32 | 33 | 34 | 35 | 38 | 41 | 44 | 47 | 48 | {{#if user}} 49 | 50 | 53 | 56 | 59 | 62 | 63 | {{/if}} 64 | 65 | 68 | 71 | 74 | 77 | 78 | 79 | 82 | 85 | 88 | 91 | 92 | 93 | 96 | 103 | 110 | 117 | 118 | 119 | 122 | {{#if user}} 123 | 126 | 129 | 132 | 133 | {{else}} 134 | 137 | 140 | 143 | {{/if}} 144 | 145 | 146 | 149 | {{#if use2fa}} 150 | 154 | {{else}} 155 | 158 | 161 | 164 | {{/if}} 165 | 166 | 167 |
20 |   21 | 23 | IMAP 24 | 26 | POP3 27 | 29 | SMTP 30 |
36 | Description 37 | 39 | Access all messages and mailboxes 40 | 42 | Access INBOX 43 | 45 | Send messages 46 |
51 | E-mail address 52 | 54 | {{user.address}} 55 | 57 | {{user.address}} 58 | 60 | {{user.address}} 61 |
66 | Server 67 | 69 | {{setup.imap.hostname}} 70 | 72 | {{setup.pop3.hostname}} 73 | 75 | {{setup.smtp.hostname}} 76 |
80 | Port 81 | 83 | {{setup.imap.port}} 84 | 86 | {{setup.pop3.port}} 87 | 89 | {{setup.smtp.port}} 90 |
94 | Security 95 | 97 | {{#if setup.imap.secure}} 98 | TLS/SSL 99 | {{else}} 100 | STARTTLS 101 | {{/if}} 102 | 104 | {{#if setup.pop3.secure}} 105 | TLS/SSL 106 | {{else}} 107 | STARTTLS 108 | {{/if}} 109 | 111 | {{#if setup.smtp.secure}} 112 | TLS/SSL 113 | {{else}} 114 | STARTTLS 115 | {{/if}} 116 |
120 | Username 121 | 124 | {{user.username}} 125 | 127 | {{user.username}} 128 | 130 | {{user.username}} 131 | 135 | Your username 136 | 138 | Your username 139 | 141 | Your username 142 |
147 | Password 148 | 151 | Two factor authentication is enabled on your account. Generate application specific passwords here to use IMAP, POP3 and SMTP. 153 | 156 | ******** 157 | 159 | ******** 160 | 162 | ******** 163 |
168 |
169 | -------------------------------------------------------------------------------- /views/account/security/gpg.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Security

4 |
5 |
6 | 7 |
8 |
9 | 10 | 11 | 14 | 15 |
16 |
17 | 18 |

 

19 | 20 |
21 | 22 | 23 |
24 | 25 |
26 |
27 |

GPG Encryption

28 |
29 |
30 | 31 |

32 | If encryption is enabled then all cleartext messages that are archived to this 33 | account are encrypted using provided public key. Private key is not known to the 34 | service so if they key is lost then messages can not be recovered. {{serviceName}} 35 | is able to display encrypted messages if Mailvelope browser extension is 37 | installed, otherwise you would have to download the messages and open these in a 38 | GPG-compatible email client. 39 |

40 | 41 |
42 | 46 | 50 | {{#if errors.encryptMessages}} 51 | {{errors.encryptMessages}} 52 | {{/if}} 53 |
54 | 55 | {{#if fingerprint}} 56 |
57 | 58 |
59 |
60 | 64 |
65 |
66 | {{fingerprint}} 67 | {{#if keyAddress}}({{keyAddress}}){{/if}} 68 |
69 |
70 |
71 | {{/if}} 72 | 73 |
74 | 76 | 79 | {{#if errors.pubKey}} 80 | {{errors.pubKey}} 81 | {{/if}} 82 | Leave empty if you do not want to replace the current 83 | key 84 |
85 | 86 |
87 | 89 |
90 | 91 |
92 |
93 |
94 |
95 |
96 |
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 |
2 |
3 |

Account

4 |
5 |
6 | 7 |
8 |
9 | 10 | 11 | 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 |
45 | 46 |
47 | 48 | 50 | 51 |
52 | Restore messages deleted in selected 53 | time range 54 |
55 | 56 |
57 | 60 |
61 | 62 |
63 |
64 | 65 |
66 |
67 |
68 |
69 |
70 |
71 | 72 | -------------------------------------------------------------------------------- /views/account/identities.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Account

4 |
5 |
6 | 7 |
8 |
9 | 10 | 11 | 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 | 31 | 34 | 37 | 40 | 43 | 44 | 45 | {{#each identities}} 46 | 47 | 50 | 51 | 58 | 59 | 66 | 67 | 70 | 71 | 77 | 78 | {{/each}} 79 | 80 |
29 |   30 | 32 | Identity name 33 | 35 | Alias Address 36 | 38 | Created 39 | 41 |   42 |
48 | {{index}} 49 | 52 | {{#if name}} 53 | {{name}} 54 | {{else}} 55 | 56 | {{/if}} 57 | 60 | {{#if main}} 61 | {{address}} (default) 62 | {{else}} 63 | {{address}} 64 | {{/if}} 65 | 68 | {{created}} 69 | 72 | {{#if ../canEdit}} 73 | Edit 74 | {{/if}} 75 | 76 |
81 | 82 |
83 |
84 | {{#if canCreate}} 85 | Add new address 86 | {{else}} 87 |

88 | Maximum amount of identities created 89 |

90 | {{/if}} 91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 | 99 | 100 | 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 | 33 |
34 | 35 |
36 | 41 |
42 | 43 |
44 | 45 | 47 |
48 | 49 |
50 | 51 | 53 |
54 | 55 |
56 | 57 |
58 | 59 | 61 |
62 |
63 | 64 |
65 | 66 | 68 |
69 | 70 |
71 | 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 | 29 | 30 | {{#if errors.name}} 31 | {{errors.name}} 32 | {{/if}} 33 |
34 | 35 |
36 | 37 |
38 | 39 | 40 | 41 | 44 | 49 | 50 |
51 | 52 | {{#if errors.username}} 53 | {{errors.username}} 54 | {{else}} 55 | Latin letters and numbers only. Dots and dashes are allowed as separators. 56 | {{/if}} 57 |
58 | 59 |
60 | 61 | 62 | {{#if errors.password}} 63 | {{errors.password}} 64 | {{/if}} 65 |
66 | 67 |
68 | 69 | 70 |
71 | 72 |
73 |
74 | 80 |
81 |
82 | 83 |
84 |
85 | 86 |
87 | {{#if recaptcha}} 88 | 94 | {{else}} 95 | 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 |
2 |
3 |

Security

4 |
5 |
6 | 7 |
8 |
9 | 10 | 11 | 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 | 35 | 38 | 41 | 44 | 47 | 48 | 49 | 50 | {{#if asps}} 51 | 52 | {{#each asps}} 53 | 54 | 57 | 60 | 63 | 70 | 75 | 76 | {{/each}} 77 | {{else}} 78 | 79 | 82 | 83 | {{/if}} 84 | 85 |
33 | # 34 | 36 | Description 37 | 39 | Created 40 | 42 | Used 43 | 45 |   46 |
55 | {{index}} 56 | 58 | {{description}} 59 | 61 | {{created}} 62 | 64 | {{#if lastUse.time}} 65 | {{lastUse.time}} 66 | {{else}} 67 | never 68 | {{/if}} 69 | 71 |
72 | 73 |
74 |
80 | No application specific passwords generated 81 |
86 |
87 | 88 |
89 | 90 | 91 |
92 |
93 |
94 |

Create new application specific password

95 |
96 |
97 | 98 |
99 | 100 | 101 | {{#if errors.description}} 102 | {{errors.description}} 103 | {{/if}} 104 |
105 | 106 |
107 | 108 |
109 | 110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 | 119 | 120 | 121 | 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 |
2 |
3 |

Security

4 |
5 |
6 | 7 |
8 |
9 | 10 | 11 | 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 | 50 | 60 | 61 | 62 | {{#if enabled2fa}} 63 | 64 | 71 | 81 | 82 | {{/if}} 83 |
44 | {{#if enabled2fa}} 45 | Two factor authentication is Enabled 46 | {{else}} 47 | Two factor authentication is Disabled 48 | {{/if}} 49 | 51 | {{#if enabled2fa}} 52 | 53 | {{else}} 54 |
55 | 56 | 57 |
58 | {{/if}} 59 |
65 | {{#if enabledU2f}} 66 | U2F security key is Enabled 67 | {{else}} 68 | U2F security key is Disabled 69 | {{/if}} 70 | 72 | {{#if enabledU2f}} 73 | 74 | {{else}} 75 |
76 | 77 | 78 |
79 | {{/if}} 80 |
84 |
85 |
86 |
87 |
88 |
89 | 90 | 91 | 111 | 112 | 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 | } --------------------------------------------------------------------------------