├── .editorconfig ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── Vagrantfile ├── api ├── docs │ ├── generate-docs.sh │ └── template.hbs └── mailinabox.yml ├── conf ├── dovecot-mailboxes.conf ├── fail2ban │ ├── filter.d │ │ ├── dovecotimap.conf │ │ ├── miab-management-daemon.conf │ │ ├── miab-munin.conf │ │ ├── miab-owncloud.conf │ │ ├── miab-postfix-submission.conf │ │ └── miab-roundcube.conf │ └── jails.conf ├── ios-profile.xml ├── mailinabox.service ├── mozilla-autoconfig.xml ├── mta-sts.txt ├── munin.service ├── nginx-alldomains.conf ├── nginx-primaryonly.conf ├── nginx-ssl.conf ├── nginx-top.conf ├── nginx.conf ├── postfix_outgoing_mail_header_filters ├── sieve-spam.txt ├── www_default.html └── zpush │ ├── autodiscover_config.php │ ├── backend_caldav.php │ ├── backend_carddav.php │ ├── backend_combined.php │ └── backend_imap.php ├── management ├── auth.py ├── backup.py ├── cli.py ├── csr_country_codes.tsv ├── daemon.py ├── daily_tasks.sh ├── dns_update.py ├── email_administrator.py ├── mail_log.py ├── mailconfig.py ├── mfa.py ├── munin_start.sh ├── ssl_certificates.py ├── status_checks.py ├── templates │ ├── aliases.html │ ├── custom-dns.html │ ├── external-dns.html │ ├── index.html │ ├── login.html │ ├── mail-guide.html │ ├── mfa.html │ ├── munin.html │ ├── ssl.html │ ├── sync-guide.html │ ├── system-backup.html │ ├── system-status.html │ ├── users.html │ ├── web.html │ └── welcome.html ├── utils.py ├── web_update.py └── wsgi.py ├── security.md ├── setup ├── bootstrap.sh ├── dkim.sh ├── dns.sh ├── firstuser.sh ├── functions.sh ├── mail-dovecot.sh ├── mail-postfix.sh ├── mail-users.sh ├── management.sh ├── migrate.py ├── munin.sh ├── network-checks.sh ├── nextcloud.sh ├── preflight.sh ├── questions.sh ├── spamassassin.sh ├── ssl.sh ├── start.sh ├── system.sh ├── web.sh ├── webmail.sh └── zpush.sh ├── tests ├── fail2ban.py ├── pip-requirements.txt ├── test_dns.py ├── test_mail.py ├── test_smtp_server.py ├── tls.py └── tls_results.txt └── tools ├── archive_conf_files.sh ├── dns_update ├── editconf.py ├── mail.py ├── owncloud-restore.sh ├── owncloud-unlockadmin.sh ├── parse-nginx-log-bootstrap-accesses.py ├── readable_bash.py ├── ssl_cleanup └── web_update /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | indent_style = space 9 | indent_size = 4 10 | end_of_line = lf 11 | charset = utf-8 12 | trim_trailing_whitespace = true 13 | insert_final_newline = true 14 | 15 | [Makefile] 16 | indent_style = tab 17 | indent_size = 4 18 | 19 | [Vagrantfile] 20 | indent_size = 2 21 | 22 | [*.rb] 23 | indent_size = 2 24 | 25 | [*.py] 26 | indent_style = tab 27 | 28 | [*.js] 29 | indent_size = 2 30 | 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | tests/__pycache__/ 3 | management/__pycache__/ 4 | tools/__pycache__/ 5 | externals/ 6 | .env 7 | .vagrant 8 | api/docs/api-docs.html 9 | *.code-workspace 10 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Mail-in-a-Box Code of Conduct 2 | 3 | Mail-in-a-Box is an open source community project about working, as a group, to empower ourselves and others to have control over our own digital communications. Just as we hope to increase technological diversity on the Internet through decentralization, we also believe that diverse viewpoints and voices among our community members foster innovation and creative solutions to the challenges we face. 4 | 5 | We are committed to providing a safe, welcoming, and harassment-free space for collaboration, for everyone, without regard to age, disability, economic situation, ethnicity, gender identity and expression, language fluency, level of knowledge or experience, nationality, personal appearance, race, religion, sexual identity and orientation, or any other attribute. Community comes first. This policy supersedes all other project goals. 6 | 7 | The maintainers of Mail-in-a-Box share the dual responsibility of leading by example and enforcing these policies as necessary to maintain an open and welcoming environment. All community members should be excellent to each other. 8 | 9 | ## Scope 10 | 11 | This Code of Conduct applies to all places where Mail-in-a-Box community activity is occurring, including on GitHub, in discussion forums, on Slack, on social media, and in real life. The Code of Conduct applies not only on websites/at events run by the Mail-in-a-Box community (e.g. our GitHub organization, our Slack team) but also at any other location where the Mail-in-a-Box community is present (e.g. in issues of other GitHub organizations where Mail-in-a-Box community members are discussing problems related to Mail-in-a-Box, or real-life professional conferences), or whenever a Mail-in-a-Box community member is representing Mail-in-a-Box to the public at large or acting on behalf of Mail-in-a-Box. 12 | 13 | This code does not apply to activity on a server running Mail-in-a-Box software, unless your server is hosting a service for the Mail-in-a-Box community at large. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to creating a positive environment include: 18 | 19 | * Using welcoming and inclusive language 20 | * Being respectful of differing viewpoints and experiences 21 | * Gracefully accepting constructive criticism 22 | * Showing empathy towards other community members 23 | * Making room for new and quieter voices 24 | 25 | Examples of unacceptable behavior by participants include: 26 | 27 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 28 | * Trolling, insulting/derogatory/unwelcome comments, and personal or political attacks 29 | * Public or private harassment 30 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 31 | * Aggressive and micro-aggressive behavior, such as unconstructive criticism, providing corrections that do not improve the conversation (sometimes referred to as "well actually"s), repeatedly interrupting or talking over someone else, feigning surprise at someone's lack of knowledge or awareness about a topic, or subtle prejudice (for example, comments like "That's so easy my grandmother could do it.", which is prejudicial toward grandmothers). 32 | * Other conduct which could reasonably be considered inappropriate in a professional setting 33 | * Retaliating against anyone who reports a violation of this code. 34 | 35 | We will not tolerate harassment. Harassment is any unwelcome or hostile behavior towards another person for any reason. This includes, but is not limited to, offensive verbal comments related to personal characteristics or choices, sexual images or comments, deliberate intimidation, bullying, stalking, following, harassing photography or recording, sustained disruption of discussion or events, nonconsensual publication of private comments, inappropriate physical contact, or unwelcome sexual attention. Conduct need not be intentional to be harassment. 36 | 37 | ## Enforcement 38 | 39 | We will remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not consistent with this Code of Conduct. We may ban, temporarily or permanently, any contributor for violating this code, when appropriate. 40 | 41 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project lead, [Joshua Tauberer](https://razor.occams.info/). All reports will be treated confidentially, impartially, consistently, and swiftly. 42 | 43 | Because the need for confidentiality for all parties involved in an enforcement action outweighs the goals of openness, limited information will be shared with the Mail-in-a-Box community regarding enforcement actions that have taken place. 44 | 45 | ## Attribution 46 | 47 | This Code of Conduct is adapted from the [Contributor Covenant, version 1.4](http://contributor-covenant.org/version/1/4) and the code of conduct of [Code for DC](http://codefordc.org/resources/codeofconduct.html). 48 | 49 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Mail-in-a-Box is an open source project. Your contributions and pull requests are welcome. 4 | 5 | ## Development 6 | 7 | To start developing Mail-in-a-Box, [clone the repository](https://github.com/mail-in-a-box/mailinabox) and familiarize yourself with the code. 8 | 9 | $ git clone https://github.com/mail-in-a-box/mailinabox 10 | 11 | ### Vagrant and VirtualBox 12 | 13 | We recommend you use [Vagrant](https://www.vagrantup.com/intro/getting-started/install.html) and [VirtualBox](https://www.virtualbox.org/wiki/Downloads) for development. Please install them first. 14 | 15 | With Vagrant set up, the following should boot up Mail-in-a-Box inside a virtual machine: 16 | 17 | $ vagrant up --provision 18 | 19 | _If you're seeing an error message about your *IP address being listed in the Spamhaus Block List*, simply uncomment the `export SKIP_NETWORK_CHECKS=1` line in `Vagrantfile`. It's normal, you're probably using a dynamic IP address assigned by your Internet provider–they're almost all listed._ 20 | 21 | ### Modifying your `hosts` file 22 | 23 | After a while, Mail-in-a-Box will be available at `192.168.56.4` (unless you changed that in your `Vagrantfile`). To be able to use the web-based bits, we recommend to add a hostname to your `hosts` file: 24 | 25 | $ echo "192.168.56.4 mailinabox.lan" | sudo tee -a /etc/hosts 26 | 27 | You should now be able to navigate to https://mailinabox.lan/admin using your browser. There should be an initial admin user with the name `me@mailinabox.lan` and the password `12345678`. 28 | 29 | ### Making changes 30 | 31 | Your working copy of Mail-in-a-Box will be mounted inside your VM at `/vagrant`. Any change you make locally will appear inside your VM automatically. 32 | 33 | Running `vagrant up --provision` again will repeat the installation with your modifications. 34 | 35 | Alternatively, you can also ssh into the VM using: 36 | 37 | $ vagrant ssh 38 | 39 | Once inside the VM, you can re-run individual parts of the setup like in this example: 40 | 41 | vm$ cd /vagrant 42 | vm$ sudo setup/owncloud.sh # replace with script you'd like to re-run 43 | 44 | ### Tests 45 | 46 | Mail-in-a-Box needs more tests. If you're still looking for a way to help out, writing and contributing tests would be a great start! 47 | 48 | ## Public domain 49 | 50 | This project is in the public domain. Copyright and related rights in the work worldwide are waived through the [CC0 1.0 Universal public domain dedication][CC0]. See the LICENSE file in this directory. 51 | 52 | All contributions to this project must be released under the same CC0 wavier. By submitting a pull request or patch, you are agreeing to comply with this waiver of copyright interest. 53 | 54 | [CC0]: http://creativecommons.org/publicdomain/zero/1.0/ 55 | 56 | ## Code of Conduct 57 | 58 | This project has a [Code of Conduct](CODE_OF_CONDUCT.md). Please review it when joining our community. 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Mail-in-a-Box 2 | ============= 3 | 4 | By [@JoshData](https://github.com/JoshData) and [contributors](https://github.com/mail-in-a-box/mailinabox/graphs/contributors). 5 | 6 | Mail-in-a-Box helps individuals take back control of their email by defining a one-click, easy-to-deploy SMTP+everything else server: a mail server in a box. 7 | 8 | **Please see [https://mailinabox.email](https://mailinabox.email) for the project's website and setup guide!** 9 | 10 | * * * 11 | 12 | Our goals are to: 13 | 14 | * Make deploying a good mail server easy. 15 | * Promote [decentralization](http://redecentralize.org/), innovation, and privacy on the web. 16 | * Have automated, auditable, and [idempotent](https://web.archive.org/web/20190518072631/https://sharknet.us/2014/02/01/automated-configuration-management-challenges-with-idempotency/) configuration. 17 | * **Not** make a totally unhackable, NSA-proof server. 18 | * **Not** make something customizable by power users. 19 | 20 | Additionally, this project has a [Code of Conduct](CODE_OF_CONDUCT.md), which supersedes the goals above. Please review it when joining our community. 21 | 22 | 23 | In The Box 24 | ---------- 25 | 26 | Mail-in-a-Box turns a fresh Ubuntu 22.04 LTS 64-bit machine into a working mail server by installing and configuring various components. 27 | 28 | It is a one-click email appliance. There are no user-configurable setup options. It "just works." 29 | 30 | The components installed are: 31 | 32 | * SMTP ([postfix](http://www.postfix.org/)), IMAP ([Dovecot](http://dovecot.org/)), CardDAV/CalDAV ([Nextcloud](https://nextcloud.com/)), and Exchange ActiveSync ([z-push](http://z-push.org/)) servers 33 | * Webmail ([Roundcube](http://roundcube.net/)), mail filter rules (thanks to Roundcube and Dovecot), and email client autoconfig settings (served by [nginx](http://nginx.org/)) 34 | * Spam filtering ([spamassassin](https://spamassassin.apache.org/)) and greylisting ([postgrey](http://postgrey.schweikert.ch/)) 35 | * DNS ([nsd4](https://www.nlnetlabs.nl/projects/nsd/)) with [SPF](https://en.wikipedia.org/wiki/Sender_Policy_Framework), DKIM ([OpenDKIM](http://www.opendkim.org/)), [DMARC](https://en.wikipedia.org/wiki/DMARC), [DNSSEC](https://en.wikipedia.org/wiki/DNSSEC), [DANE TLSA](https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities), [MTA-STS](https://tools.ietf.org/html/rfc8461), and [SSHFP](https://tools.ietf.org/html/rfc4255) policy records automatically set 36 | * TLS certificates are automatically provisioned using [Let's Encrypt](https://letsencrypt.org/) for protecting https and all of the other services on the box 37 | * Backups ([duplicity](http://duplicity.nongnu.org/)), firewall ([ufw](https://launchpad.net/ufw)), intrusion protection ([fail2ban](http://www.fail2ban.org/wiki/index.php/Main_Page)), and basic system monitoring ([munin](http://munin-monitoring.org/)) 38 | 39 | It also includes system management tools: 40 | 41 | * Comprehensive health monitoring that checks each day that services are running, ports are open, TLS certificates are valid, and DNS records are correct 42 | * A control panel for adding/removing mail users, aliases, custom DNS records, configuring backups, etc. 43 | * An API for all of the actions on the control panel 44 | 45 | Internationalized domain names are supported and configured easily (but SMTPUTF8 is not supported, unfortunately). 46 | 47 | It also supports static website hosting since the box is serving HTTPS anyway. (To serve a website for your domains elsewhere, just add a custom DNS "A" record in you Mail-in-a-Box's control panel to point domains to another server.) 48 | 49 | For more information on how Mail-in-a-Box handles your privacy, see the [security details page](security.md). 50 | 51 | 52 | Installation 53 | ------------ 54 | 55 | See the [setup guide](https://mailinabox.email/guide.html) for detailed, user-friendly instructions. 56 | 57 | For experts, start with a completely fresh (really, I mean it) Ubuntu 22.04 LTS 64-bit machine. On the machine... 58 | 59 | Clone this repository and checkout the tag corresponding to the most recent release (which you can find in the tags or releases lists on GitHub): 60 | 61 | $ git clone https://github.com/mail-in-a-box/mailinabox 62 | $ cd mailinabox 63 | $ git checkout TAGNAME 64 | 65 | Begin the installation. 66 | 67 | $ sudo setup/start.sh 68 | 69 | The installation will install, uninstall, and configure packages to turn the machine into a working, good mail server. 70 | 71 | For help, DO NOT contact Josh directly --- I don't do tech support by email or tweet (no exceptions). 72 | 73 | Post your question on the [discussion forum](https://discourse.mailinabox.email/) instead, where maintainers and Mail-in-a-Box users may be able to help you. 74 | 75 | Note that while we want everything to "just work," we can't control the rest of the Internet. Other mail services might block or spam-filter email sent from your Mail-in-a-Box. 76 | This is a challenge faced by everyone who runs their own mail server, with or without Mail-in-a-Box. See our discussion forum for tips about that. 77 | 78 | 79 | Contributing and Development 80 | ---------------------------- 81 | 82 | Mail-in-a-Box is an open source project. Your contributions and pull requests are welcome. See [CONTRIBUTING](CONTRIBUTING.md) to get started. 83 | 84 | 85 | The Acknowledgements 86 | -------------------- 87 | 88 | This project was inspired in part by the ["NSA-proof your email in 2 hours"](http://sealedabstract.com/code/nsa-proof-your-e-mail-in-2-hours/) blog post by Drew Crawford, [Sovereign](https://github.com/sovereign/sovereign) by Alex Payne, and conversations with @shevski, @konklone, and @GregElin. 89 | 90 | Mail-in-a-Box is similar to [iRedMail](http://www.iredmail.org/) and [Modoboa](https://github.com/tonioo/modoboa). 91 | 92 | 93 | The History 94 | ----------- 95 | 96 | * In 2007 I wrote a relatively popular Mozilla Thunderbird extension that added client-side SPF and DKIM checks to mail to warn users about possible phishing: [add-on page](https://addons.mozilla.org/en-us/thunderbird/addon/sender-verification-anti-phish/), [source](https://github.com/JoshData/thunderbird-spf). 97 | * In August 2013 I began Mail-in-a-Box by combining my own mail server configuration with the setup in ["NSA-proof your email in 2 hours"](http://sealedabstract.com/code/nsa-proof-your-e-mail-in-2-hours/) and making the setup steps reproducible with bash scripts. 98 | * Mail-in-a-Box was a semifinalist in the 2014 [Knight News Challenge](https://www.newschallenge.org/challenge/2014/submissions/mail-in-a-box), but it was not selected as a winner. 99 | * Mail-in-a-Box hit the front page of Hacker News in [April](https://news.ycombinator.com/item?id=7634514) 2014, [September](https://news.ycombinator.com/item?id=8276171) 2014, [May](https://news.ycombinator.com/item?id=9624267) 2015, and [November](https://news.ycombinator.com/item?id=13050500) 2016. 100 | * FastCompany mentioned Mail-in-a-Box a [roundup of privacy projects](http://www.fastcompany.com/3047645/your-own-private-cloud) on June 26, 2015. 101 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | Vagrant.configure("2") do |config| 5 | config.vm.box = "ubuntu/jammy64" 6 | 7 | # Network config: Since it's a mail server, the machine must be connected 8 | # to the public web. However, we currently don't want to expose SSH since 9 | # the machine's box will let anyone log into it. So instead we'll put the 10 | # machine on a private network. 11 | config.vm.hostname = "mailinabox.lan" 12 | config.vm.network "private_network", ip: "192.168.56.4" 13 | 14 | config.vm.provision :shell, :inline => <<-SH 15 | # Set environment variables so that the setup script does 16 | # not ask any questions during provisioning. We'll let the 17 | # machine figure out its own public IP. 18 | export NONINTERACTIVE=1 19 | export PUBLIC_IP=auto 20 | export PUBLIC_IPV6=auto 21 | export PRIMARY_HOSTNAME=auto 22 | #export SKIP_NETWORK_CHECKS=1 23 | 24 | # Start the setup script. 25 | cd /vagrant 26 | setup/start.sh 27 | SH 28 | end 29 | -------------------------------------------------------------------------------- /api/docs/generate-docs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # Requirements: 4 | # - Node.js 5 | # - redoc-cli (`npm install redoc-cli -g`) 6 | 7 | redoc-cli bundle ../mailinabox.yml \ 8 | -t template.hbs \ 9 | -o api-docs.html \ 10 | --templateOptions.metaDescription="Mail-in-a-Box HTTP API" \ 11 | --title="Mail-in-a-Box HTTP API" \ 12 | --options.expandSingleSchemaField \ 13 | --options.hideSingleRequestSampleTab \ 14 | --options.jsonSampleExpandLevel=10 \ 15 | --options.hideDownloadButton \ 16 | --options.theme.logo.maxHeight=180px \ 17 | --options.theme.logo.maxWidth=180px \ 18 | --options.theme.colors.primary.main="#C52" \ 19 | --options.theme.typography.fontSize=16px \ 20 | --options.theme.typography.fontFamily="Raleway, sans-serif" \ 21 | --options.theme.typography.headings.fontFamily="Ubuntu, Arial, sans-serif" \ 22 | --options.theme.typography.code.fontSize=15px \ 23 | --options.theme.typography.code.fontFamily='"Source Code Pro", monospace' -------------------------------------------------------------------------------- /api/docs/template.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{title}} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 24 | {{{redocHead}}} 25 | 26 | 27 | 28 | {{{redocHTML}}} 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /conf/dovecot-mailboxes.conf: -------------------------------------------------------------------------------- 1 | ## NOTE: This file is automatically generated by Mail-in-a-Box. 2 | ## Do not edit this file. It is continually updated by 3 | ## Mail-in-a-Box and your changes will be lost. 4 | ## 5 | ## Mail-in-a-Box machines are not meant to be modified. 6 | ## If you modify any system configuration you are on 7 | ## your own --- please do not ask for help from us. 8 | 9 | namespace inbox { 10 | # Automatically create & subscribe some folders. 11 | # * Create and subscribe the INBOX folder. 12 | # * Our sieve rule for spam expects that the Spam folder exists. 13 | # * Z-Push must be configured with the same settings in conf/zpush/backend_imap.php (#580). 14 | 15 | # MUA notes: 16 | # * Roundcube will show an error if the user tries to delete a message before the Trash folder exists (#359). 17 | # * K-9 mail will poll every 90 seconds if a Drafts folder does not exist. 18 | # * Apple's OS X Mail app will create 'Sent Messages' if it doesn't see a folder with the \Sent flag (#571, #573) and won't be able to archive messages unless 'Archive' exists (#581). 19 | # * Thunderbird's default in its UI is 'Archives' (plural) but it will configure new accounts to use whatever we say here (#581). 20 | 21 | # auto: 22 | # 'create' will automatically create this mailbox. 23 | # 'subscribe' will both create and subscribe to the mailbox. 24 | 25 | # special_use is a space separated list of IMAP SPECIAL-USE 26 | # attributes as specified by RFC 6154: 27 | # \All \Archive \Drafts \Flagged \Junk \Sent \Trash 28 | 29 | mailbox INBOX { 30 | auto = subscribe 31 | } 32 | mailbox Spam { 33 | special_use = \Junk 34 | auto = subscribe 35 | } 36 | mailbox Drafts { 37 | special_use = \Drafts 38 | auto = subscribe 39 | } 40 | mailbox Sent { 41 | special_use = \Sent 42 | auto = subscribe 43 | } 44 | mailbox Trash { 45 | special_use = \Trash 46 | auto = subscribe 47 | } 48 | mailbox Archive { 49 | special_use = \Archive 50 | auto = subscribe 51 | } 52 | 53 | # dovevot's standard mailboxes configuration file marks two sent folders 54 | # with the \Sent attribute, just in case clients don't agree about which 55 | # they're using. We'll keep that, plus add Junk as an alternative for Spam. 56 | # These are not auto-created. 57 | mailbox "Sent Messages" { 58 | special_use = \Sent 59 | } 60 | mailbox Junk { 61 | special_use = \Junk 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /conf/fail2ban/filter.d/dovecotimap.conf: -------------------------------------------------------------------------------- 1 | # Fail2Ban filter Dovecot authentication and pop3/imap/managesieve server 2 | # For Mail-in-a-Box 3 | 4 | [INCLUDES] 5 | 6 | before = common.conf 7 | 8 | [Definition] 9 | 10 | _daemon = (auth|dovecot(-auth)?|auth-worker) 11 | 12 | failregex = ^%(__prefix_line)s(pop3|imap|managesieve)-login: (Info: )?(Aborted login|Disconnected)(: Inactivity)? \(((no auth attempts|auth failed, \d+ attempts)( in \d+ secs)?|tried to use (disabled|disallowed) \S+ auth)\):( user=<\S*>,)?( method=\S+,)? rip=, lip=(\d{1,3}\.){3}\d{1,3}(, TLS( handshaking)?(: Disconnected)?)?(, session=<\S+>)?\s*$ 13 | 14 | ignoreregex = 15 | 16 | # DEV Notes: 17 | # * the first regex is essentially a copy of pam-generic.conf 18 | # * Probably doesn't do dovecot sql/ldap backends properly 19 | # 20 | # Author: Martin Waschbuesch 21 | # Daniel Black (rewrote with begin and end anchors) 22 | # Mail-in-a-Box (swapped session=...) 23 | -------------------------------------------------------------------------------- /conf/fail2ban/filter.d/miab-management-daemon.conf: -------------------------------------------------------------------------------- 1 | # Fail2Ban filter Mail-in-a-Box management daemon 2 | 3 | [INCLUDES] 4 | 5 | before = common.conf 6 | 7 | [Definition] 8 | 9 | _daemon = mailinabox 10 | 11 | failregex = Mail-in-a-Box Management Daemon: Failed login attempt from ip - timestamp .* 12 | ignoreregex = 13 | -------------------------------------------------------------------------------- /conf/fail2ban/filter.d/miab-munin.conf: -------------------------------------------------------------------------------- 1 | [INCLUDES] 2 | 3 | before = common.conf 4 | 5 | [Definition] 6 | failregex= - .*GET /admin/munin/.* HTTP/\d+\.\d+\" 401.* 7 | ignoreregex = 8 | -------------------------------------------------------------------------------- /conf/fail2ban/filter.d/miab-owncloud.conf: -------------------------------------------------------------------------------- 1 | [INCLUDES] 2 | 3 | before = common.conf 4 | 5 | [Definition] 6 | datepattern = %%Y-%%m-%%d %%H:%%M:%%S 7 | failregex=Login failed: .*Remote IP: '[\)'] 8 | ignoreregex = 9 | -------------------------------------------------------------------------------- /conf/fail2ban/filter.d/miab-postfix-submission.conf: -------------------------------------------------------------------------------- 1 | [INCLUDES] 2 | 3 | before = common.conf 4 | 5 | [Definition] 6 | failregex=postfix/submission/smtpd.*warning.*\[\]: .* authentication (failed|aborted) 7 | ignoreregex = 8 | -------------------------------------------------------------------------------- /conf/fail2ban/filter.d/miab-roundcube.conf: -------------------------------------------------------------------------------- 1 | [INCLUDES] 2 | 3 | before = common.conf 4 | 5 | [Definition] 6 | 7 | failregex = IMAP Error: Login failed for .*? from \. AUTHENTICATE.* 8 | 9 | ignoreregex = 10 | -------------------------------------------------------------------------------- /conf/fail2ban/jails.conf: -------------------------------------------------------------------------------- 1 | # Fail2Ban configuration file for Mail-in-a-Box. Do not edit. 2 | # This file is re-generated on updates. 3 | 4 | [DEFAULT] 5 | # Whitelist our own IP addresses. 127.0.0.1/8 is the default. But our status checks 6 | # ping services over the public interface so we should whitelist that address of 7 | # ours too. The string is substituted during installation. 8 | ignoreip = 127.0.0.1/8 PUBLIC_IP ::1 PUBLIC_IPV6 9 | 10 | [dovecot] 11 | enabled = true 12 | filter = dovecotimap 13 | logpath = /var/log/mail.log 14 | findtime = 30 15 | maxretry = 20 16 | 17 | [miab-management] 18 | enabled = true 19 | filter = miab-management-daemon 20 | port = http,https 21 | logpath = /var/log/syslog 22 | maxretry = 20 23 | findtime = 30 24 | 25 | [miab-munin] 26 | enabled = true 27 | port = http,https 28 | filter = miab-munin 29 | logpath = /var/log/nginx/access.log 30 | maxretry = 20 31 | findtime = 30 32 | 33 | [miab-owncloud] 34 | enabled = true 35 | port = http,https 36 | filter = miab-owncloud 37 | logpath = STORAGE_ROOT/owncloud/nextcloud.log 38 | maxretry = 20 39 | findtime = 120 40 | 41 | [miab-postfix465] 42 | enabled = true 43 | port = 465 44 | filter = miab-postfix-submission 45 | logpath = /var/log/mail.log 46 | maxretry = 20 47 | findtime = 30 48 | 49 | [miab-postfix587] 50 | enabled = true 51 | port = 587 52 | filter = miab-postfix-submission 53 | logpath = /var/log/mail.log 54 | maxretry = 20 55 | findtime = 30 56 | 57 | [miab-roundcube] 58 | enabled = true 59 | port = http,https 60 | filter = miab-roundcube 61 | logpath = /var/log/roundcubemail/errors.log 62 | maxretry = 20 63 | findtime = 30 64 | 65 | [recidive] 66 | enabled = true 67 | maxretry = 10 68 | action = iptables-allports[name=recidive] 69 | # In the recidive section of jail.conf the action contains: 70 | # 71 | # action = iptables-allports[name=recidive] 72 | # sendmail-whois-lines[name=recidive, logpath=/var/log/fail2ban.log] 73 | # 74 | # The last line on the action will sent an email to the configured address. This mail will 75 | # notify the administrator that someone has been repeatedly triggering one of the other jails. 76 | # By default we don't configure this address and no action is required from the admin anyway. 77 | # So the notification is omitted. This will prevent message appearing in the mail.log that mail 78 | # can't be delivered to fail2ban@$HOSTNAME. 79 | 80 | [postfix-sasl] 81 | enabled = true 82 | 83 | [sshd] 84 | enabled = true 85 | maxretry = 7 86 | bantime = 3600 87 | -------------------------------------------------------------------------------- /conf/ios-profile.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 11 | 12 | PayloadContent 13 | 14 | 15 | CalDAVAccountDescription 16 | PRIMARY_HOSTNAME calendar 17 | CalDAVHostName 18 | PRIMARY_HOSTNAME 19 | CalDAVPort 20 | 443 21 | CalDAVUseSSL 22 | 23 | PayloadDescription 24 | PRIMARY_HOSTNAME (Mail-in-a-Box) 25 | PayloadDisplayName 26 | PRIMARY_HOSTNAME calendar 27 | PayloadIdentifier 28 | email.mailinabox.mobileconfig.PRIMARY_HOSTNAME.CalDAV 29 | PayloadOrganization 30 | 31 | PayloadType 32 | com.apple.caldav.account 33 | PayloadUUID 34 | UUID1 35 | PayloadVersion 36 | 1 37 | 38 | 39 | EmailAccountDescription 40 | PRIMARY_HOSTNAME mail 41 | EmailAccountType 42 | EmailTypeIMAP 43 | IncomingMailServerAuthentication 44 | EmailAuthPassword 45 | IncomingMailServerHostName 46 | PRIMARY_HOSTNAME 47 | IncomingMailServerPortNumber 48 | 993 49 | IncomingMailServerUseSSL 50 | 51 | OutgoingMailServerAuthentication 52 | EmailAuthPassword 53 | OutgoingMailServerHostName 54 | PRIMARY_HOSTNAME 55 | OutgoingMailServerPortNumber 56 | 465 57 | OutgoingMailServerUseSSL 58 | 59 | OutgoingPasswordSameAsIncomingPassword 60 | 61 | PayloadDescription 62 | PRIMARY_HOSTNAME (Mail-in-a-Box) 63 | PayloadDisplayName 64 | PRIMARY_HOSTNAME mail 65 | PayloadIdentifier 66 | email.mailinabox.mobileconfig.PRIMARY_HOSTNAME.E-Mail 67 | PayloadOrganization 68 | 69 | PayloadType 70 | com.apple.mail.managed 71 | PayloadUUID 72 | UUID2 73 | PayloadVersion 74 | 1 75 | PreventAppSheet 76 | 77 | PreventMove 78 | 79 | SMIMEEnabled 80 | 81 | 82 | 83 | CardDAVAccountDescription 84 | PRIMARY_HOSTNAME contacts 85 | CardDAVHostName 86 | PRIMARY_HOSTNAME 87 | CardDAVPort 88 | 443 89 | CardDAVPrincipalURL 90 | /cloud/remote.php/carddav/addressbooks/ 91 | CardDAVUseSSL 92 | 93 | PayloadDescription 94 | PRIMARY_HOSTNAME (Mail-in-a-Box) 95 | PayloadDisplayName 96 | PRIMARY_HOSTNAME contacts 97 | PayloadIdentifier 98 | email.mailinabox.mobileconfig.PRIMARY_HOSTNAME.carddav 99 | PayloadOrganization 100 | 101 | PayloadType 102 | com.apple.carddav.account 103 | PayloadUUID 104 | UUID3 105 | PayloadVersion 106 | 1 107 | 108 | 109 | PayloadDescription 110 | PRIMARY_HOSTNAME (Mail-in-a-Box) 111 | PayloadDisplayName 112 | PRIMARY_HOSTNAME 113 | PayloadIdentifier 114 | email.mailinabox.mobileconfig.PRIMARY_HOSTNAME 115 | PayloadOrganization 116 | 117 | PayloadRemovalDisallowed 118 | 119 | PayloadType 120 | Configuration 121 | PayloadUUID 122 | UUID4 123 | PayloadVersion 124 | 1 125 | 126 | 127 | -------------------------------------------------------------------------------- /conf/mailinabox.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Mail-in-a-Box System Management Service 3 | After=multi-user.target 4 | 5 | [Service] 6 | Type=idle 7 | IgnoreSIGPIPE=False 8 | ExecStart=/usr/local/lib/mailinabox/start 9 | 10 | [Install] 11 | WantedBy=multi-user.target 12 | -------------------------------------------------------------------------------- /conf/mozilla-autoconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | PRIMARY_HOSTNAME 5 | 6 | PRIMARY_HOSTNAME (Mail-in-a-Box) 7 | PRIMARY_HOSTNAME 8 | 9 | 10 | PRIMARY_HOSTNAME 11 | 993 12 | SSL 13 | %EMAILADDRESS% 14 | password-cleartext 15 | 16 | 17 | 18 | PRIMARY_HOSTNAME 19 | 465 20 | SSL 21 | %EMAILADDRESS% 22 | password-cleartext 23 | true 24 | false 25 | 26 | 27 | 28 | PRIMARY_HOSTNAME website. 29 | 30 | 31 | 32 | 33 | 34 | 35 | %EMAILADDRESS% 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /conf/mta-sts.txt: -------------------------------------------------------------------------------- 1 | version: STSv1 2 | mode: MODE 3 | mx: PRIMARY_HOSTNAME 4 | max_age: 604800 5 | -------------------------------------------------------------------------------- /conf/munin.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Munin System Monitoring Startup Script 3 | After=multi-user.target 4 | 5 | [Service] 6 | Type=idle 7 | ExecStart=/usr/local/lib/mailinabox/munin_start.sh 8 | 9 | [Install] 10 | WantedBy=multi-user.target 11 | -------------------------------------------------------------------------------- /conf/nginx-alldomains.conf: -------------------------------------------------------------------------------- 1 | # Expose this directory as static files. 2 | root $ROOT; 3 | index index.html index.htm; 4 | 5 | location = /robots.txt { 6 | log_not_found off; 7 | access_log off; 8 | } 9 | 10 | location = /favicon.ico { 11 | log_not_found off; 12 | access_log off; 13 | } 14 | 15 | location = /mailinabox.mobileconfig { 16 | alias /var/lib/mailinabox/mobileconfig.xml; 17 | } 18 | location = /.well-known/autoconfig/mail/config-v1.1.xml { 19 | alias /var/lib/mailinabox/mozilla-autoconfig.xml; 20 | } 21 | location = /mail/config-v1.1.xml { 22 | alias /var/lib/mailinabox/mozilla-autoconfig.xml; 23 | } 24 | location = /.well-known/mta-sts.txt { 25 | alias /var/lib/mailinabox/mta-sts.txt; 26 | } 27 | 28 | # Roundcube Webmail configuration. 29 | rewrite ^/mail$ /mail/ redirect; 30 | rewrite ^/mail/$ /mail/index.php; 31 | location /mail/ { 32 | index index.php; 33 | alias /usr/local/lib/roundcubemail/; 34 | } 35 | location ~ /mail/config/.* { 36 | # A ~-style location is needed to give this precedence over the next block. 37 | return 403; 38 | } 39 | location ~ /mail/.*\.php { 40 | # note: ~ has precedence over a regular location block 41 | include fastcgi_params; 42 | fastcgi_split_path_info ^/mail(/.*)()$; 43 | fastcgi_index index.php; 44 | fastcgi_param SCRIPT_FILENAME /usr/local/lib/roundcubemail/$fastcgi_script_name; 45 | fastcgi_pass php-fpm; 46 | 47 | # Outgoing mail also goes through this endpoint, so increase the maximum 48 | # file upload limit to match the corresponding Postfix limit. 49 | client_max_body_size 128M; 50 | } 51 | 52 | # Z-Push (Microsoft Exchange ActiveSync) 53 | location /Microsoft-Server-ActiveSync { 54 | include /etc/nginx/fastcgi_params; 55 | fastcgi_param SCRIPT_FILENAME /usr/local/lib/z-push/index.php; 56 | fastcgi_param PHP_VALUE "include_path=.:/usr/share/php:/usr/share/pear:/usr/share/awl/inc"; 57 | fastcgi_read_timeout 630; 58 | fastcgi_pass php-fpm; 59 | 60 | # Outgoing mail also goes through this endpoint, so increase the maximum 61 | # file upload limit to match the corresponding Postfix limit. 62 | client_max_body_size 128M; 63 | } 64 | location ~* ^/autodiscover/autodiscover.xml$ { 65 | include fastcgi_params; 66 | fastcgi_param SCRIPT_FILENAME /usr/local/lib/z-push/autodiscover/autodiscover.php; 67 | fastcgi_param PHP_VALUE "include_path=.:/usr/share/php:/usr/share/pear:/usr/share/awl/inc"; 68 | fastcgi_pass php-fpm; 69 | } 70 | 71 | 72 | # ADDITIONAL DIRECTIVES HERE 73 | 74 | # Disable viewing dotfiles (.htaccess, .svn, .git, etc.) 75 | # This block is placed at the end. Nginx's precedence rules means this block 76 | # takes precedence over all non-regex matches and only regex matches that 77 | # come after it (i.e. none of those, since this is the last one.) That means 78 | # we're blocking dotfiles in the static hosted sites but not the FastCGI- 79 | # handled locations for Nextcloud (which serves user-uploaded files that might 80 | # have this pattern, see #414) or some of the other services. 81 | location ~ /\.(ht|svn|git|hg|bzr) { 82 | log_not_found off; 83 | access_log off; 84 | deny all; 85 | } 86 | -------------------------------------------------------------------------------- /conf/nginx-primaryonly.conf: -------------------------------------------------------------------------------- 1 | # Control Panel 2 | # Proxy /admin to our Python based control panel daemon. It is 3 | # listening on IPv4 only so use an IP address and not 'localhost'. 4 | location /admin/assets { 5 | alias /usr/local/lib/mailinabox/vendor/assets; 6 | } 7 | rewrite ^/admin$ /admin/; 8 | rewrite ^/admin/munin$ /admin/munin/ redirect; 9 | location /admin/ { 10 | proxy_pass http://127.0.0.1:10222/; 11 | proxy_set_header X-Forwarded-For $remote_addr; 12 | add_header X-Frame-Options "DENY"; 13 | add_header X-Content-Type-Options nosniff; 14 | add_header Content-Security-Policy "frame-ancestors 'none';"; 15 | } 16 | 17 | # Nextcloud configuration. 18 | rewrite ^/cloud$ /cloud/ redirect; 19 | rewrite ^/cloud/$ /cloud/index.php; 20 | rewrite ^/cloud/(contacts|calendar|files)$ /cloud/index.php/apps/$1/ redirect; 21 | rewrite ^(/cloud/core/doc/[^\/]+/)$ $1/index.html; 22 | rewrite ^(/cloud/oc[sm]-provider)/$ $1/index.php redirect; 23 | location /cloud/ { 24 | alias /usr/local/lib/owncloud/; 25 | location ~ ^/cloud/(build|tests|config|lib|3rdparty|templates|data|README)/ { 26 | deny all; 27 | } 28 | location ~ ^/cloud/(?:\.|autotest|occ|issue|indie|db_|console) { 29 | deny all; 30 | } 31 | # Enable paths for service and cloud federation discovery 32 | # Resolves warning in Nextcloud Settings panel 33 | location ~ ^/cloud/(oc[sm]-provider)?/([^/]+\.php)$ { 34 | index index.php; 35 | include fastcgi_params; 36 | fastcgi_param SCRIPT_FILENAME /usr/local/lib/owncloud/$1/$2; 37 | fastcgi_pass php-fpm; 38 | } 39 | } 40 | location ~ ^(/cloud)((?:/ocs)?/[^/]+\.php)(/.*)?$ { 41 | # note: ~ has precedence over a regular location block 42 | # Accept URLs like: 43 | # /cloud/index.php/apps/files/ 44 | # /cloud/index.php/apps/files/ajax/scan.php (it's really index.php; see 6fdef379adfdeac86cc2220209bdf4eb9562268d) 45 | # /cloud/ocs/v1.php/apps/files_sharing/api/v1 (see #240) 46 | # /cloud/remote.php/webdav/yourfilehere... 47 | include fastcgi_params; 48 | fastcgi_param SCRIPT_FILENAME /usr/local/lib/owncloud/$2; 49 | fastcgi_param SCRIPT_NAME $1$2; 50 | fastcgi_param PATH_INFO $3; 51 | fastcgi_param MOD_X_ACCEL_REDIRECT_ENABLED on; 52 | fastcgi_param MOD_X_ACCEL_REDIRECT_PREFIX /owncloud-xaccel; 53 | fastcgi_read_timeout 630; 54 | fastcgi_pass php-fpm; 55 | client_max_body_size 1G; 56 | fastcgi_buffers 64 4K; 57 | } 58 | location ^~ /owncloud-xaccel/ { 59 | # This directory is for MOD_X_ACCEL_REDIRECT_ENABLED. Nextcloud sends the full file 60 | # path on disk as a subdirectory under this virtual path. 61 | # We must only allow 'internal' redirects within nginx so that the filesystem 62 | # is not exposed to the world. 63 | internal; 64 | alias /; 65 | } 66 | location ~ ^/((caldav|carddav|webdav).*)$ { 67 | # Z-Push doesn't like getting a redirect, and a plain rewrite didn't work either. 68 | # Properly proxying like this seems to work fine. 69 | proxy_pass https://127.0.0.1/cloud/remote.php/$1; 70 | } 71 | rewrite ^/.well-known/host-meta /cloud/public.php?service=host-meta last; 72 | rewrite ^/.well-known/host-meta.json /cloud/public.php?service=host-meta-json last; 73 | rewrite ^/.well-known/carddav /cloud/remote.php/carddav/ redirect; 74 | rewrite ^/.well-known/caldav /cloud/remote.php/caldav/ redirect; 75 | 76 | # This addresses those service discovery issues mentioned in: 77 | # https://docs.nextcloud.com/server/23/admin_manual/issues/general_troubleshooting.html#service-discovery 78 | rewrite ^/.well-known/webfinger /cloud/index.php/.well-known/webfinger redirect; 79 | rewrite ^/.well-known/nodeinfo /cloud/index.php/.well-known/nodeinfo redirect; 80 | 81 | # ADDITIONAL DIRECTIVES HERE 82 | -------------------------------------------------------------------------------- /conf/nginx-ssl.conf: -------------------------------------------------------------------------------- 1 | # We track the Mozilla "intermediate" compatibility TLS recommendations. 2 | # Note that these settings are repeated in the SMTP and IMAP configuration. 3 | # ssl_protocols has moved to nginx.conf in bionic, check there for enabled protocols. 4 | ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; 5 | ssl_dhparam STORAGE_ROOT/ssl/dh2048.pem; 6 | 7 | # as recommended by http://nginx.org/en/docs/http/configuring_https_servers.html 8 | ssl_session_cache shared:SSL:50m; 9 | ssl_session_timeout 1d; 10 | 11 | # Buffer size of 1400 bytes fits in one MTU. 12 | # nginx 1.5.9+ ONLY 13 | ssl_buffer_size 1400; 14 | 15 | ssl_stapling on; 16 | ssl_stapling_verify on; 17 | resolver 127.0.0.1 valid=86400; 18 | resolver_timeout 10; 19 | 20 | # h/t https://gist.github.com/konklone/6532544 21 | -------------------------------------------------------------------------------- /conf/nginx-top.conf: -------------------------------------------------------------------------------- 1 | ## NOTE: This file is automatically generated by Mail-in-a-Box. 2 | ## Do not edit this file. It is continually updated by 3 | ## Mail-in-a-Box and your changes will be lost. 4 | ## 5 | ## Mail-in-a-Box machines are not meant to be modified. 6 | ## If you modify any system configuration you are on 7 | ## your own --- please do not ask for help from us. 8 | 9 | upstream php-fpm { 10 | server unix:/var/run/php/php8.0-fpm.sock; 11 | } 12 | 13 | -------------------------------------------------------------------------------- /conf/nginx.conf: -------------------------------------------------------------------------------- 1 | ## $HOSTNAME 2 | 3 | # Redirect all HTTP to HTTPS *except* the ACME challenges (Let's Encrypt TLS certificate 4 | # domain validation challenges) path, which must be served over HTTP per the ACME spec 5 | # (due to some Apache vulnerability). 6 | server { 7 | listen 80; 8 | listen [::]:80; 9 | 10 | server_name $HOSTNAME; 11 | root /tmp/invalid-path-nothing-here; 12 | 13 | # Improve privacy: Hide version an OS information on 14 | # error pages and in the "Server" HTTP-Header. 15 | server_tokens off; 16 | 17 | location / { 18 | # Redirect using the 'return' directive and the built-in 19 | # variable '$request_uri' to avoid any capturing, matching 20 | # or evaluation of regular expressions. 21 | return 301 https://$HOSTNAME$request_uri; 22 | } 23 | 24 | location /.well-known/acme-challenge/ { 25 | # This path must be served over HTTP for ACME domain validation. 26 | # We map this to a special path where our TLS cert provisioning 27 | # tool knows to store challenge response files. 28 | alias $STORAGE_ROOT/ssl/lets_encrypt/webroot/.well-known/acme-challenge/; 29 | } 30 | } 31 | 32 | # The secure HTTPS server. 33 | server { 34 | listen 443 ssl http2; 35 | listen [::]:443 ssl http2; 36 | 37 | server_name $HOSTNAME; 38 | 39 | # Improve privacy: Hide version an OS information on 40 | # error pages and in the "Server" HTTP-Header. 41 | server_tokens off; 42 | 43 | ssl_certificate $SSL_CERTIFICATE; 44 | ssl_certificate_key $SSL_KEY; 45 | 46 | # ADDITIONAL DIRECTIVES HERE 47 | } 48 | -------------------------------------------------------------------------------- /conf/postfix_outgoing_mail_header_filters: -------------------------------------------------------------------------------- 1 | # Remove the first line of the Received: header. Note that we cannot fully remove the Received: header 2 | # because OpenDKIM requires that a header be present when signing outbound mail. The first line is 3 | # where the user's home IP address would be. 4 | /^\s*Received:[^\n]*(.*)/ REPLACE Received: from authenticated-user (PRIMARY_HOSTNAME [PUBLIC_IP])$1 5 | 6 | # Remove other typically private information. 7 | /^\s*User-Agent:/ IGNORE 8 | /^\s*X-Enigmail:/ IGNORE 9 | /^\s*X-Mailer:/ IGNORE 10 | /^\s*X-Originating-IP:/ IGNORE 11 | /^\s*X-Pgp-Agent:/ IGNORE 12 | 13 | # The Mime-Version header can leak the user agent too, e.g. in Mime-Version: 1.0 (Mac OS X Mail 8.1 \(2010.6\)). 14 | /^\s*(Mime-Version:\s*[0-9\.]+)\s.+/ REPLACE $1 15 | -------------------------------------------------------------------------------- /conf/sieve-spam.txt: -------------------------------------------------------------------------------- 1 | require ["regex", "fileinto", "imap4flags"]; 2 | 3 | if allof (header :regex "X-Spam-Status" "^Yes") { 4 | fileinto "Spam"; 5 | stop; 6 | } 7 | 8 | -------------------------------------------------------------------------------- /conf/www_default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | this is a mail-in-a-box 4 | 5 | 6 | 7 |

this is a mail-in-a-box

8 |

take control of your email at https://mailinabox.email/

9 | 10 | 11 | -------------------------------------------------------------------------------- /conf/zpush/autodiscover_config.php: -------------------------------------------------------------------------------- 1 | 27 | -------------------------------------------------------------------------------- /conf/zpush/backend_caldav.php: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /conf/zpush/backend_carddav.php: -------------------------------------------------------------------------------- 1 | 32 | -------------------------------------------------------------------------------- /conf/zpush/backend_combined.php: -------------------------------------------------------------------------------- 1 | array( 13 | 'i' => array( 14 | 'name' => 'BackendIMAP', 15 | ), 16 | 'c' => array( 17 | 'name' => 'BackendCalDAV', 18 | ), 19 | 'd' => array( 20 | 'name' => 'BackendCardDAV', 21 | ), 22 | ), 23 | 'delimiter' => '/', 24 | 'folderbackend' => array( 25 | SYNC_FOLDER_TYPE_INBOX => 'i', 26 | SYNC_FOLDER_TYPE_DRAFTS => 'i', 27 | SYNC_FOLDER_TYPE_WASTEBASKET => 'i', 28 | SYNC_FOLDER_TYPE_SENTMAIL => 'i', 29 | SYNC_FOLDER_TYPE_OUTBOX => 'i', 30 | SYNC_FOLDER_TYPE_TASK => 'c', 31 | SYNC_FOLDER_TYPE_APPOINTMENT => 'c', 32 | SYNC_FOLDER_TYPE_CONTACT => 'd', 33 | SYNC_FOLDER_TYPE_NOTE => 'c', 34 | SYNC_FOLDER_TYPE_JOURNAL => 'c', 35 | SYNC_FOLDER_TYPE_OTHER => 'i', 36 | SYNC_FOLDER_TYPE_USER_MAIL => 'i', 37 | SYNC_FOLDER_TYPE_USER_APPOINTMENT => 'c', 38 | SYNC_FOLDER_TYPE_USER_CONTACT => 'd', 39 | SYNC_FOLDER_TYPE_USER_TASK => 'c', 40 | SYNC_FOLDER_TYPE_USER_JOURNAL => 'c', 41 | SYNC_FOLDER_TYPE_USER_NOTE => 'c', 42 | SYNC_FOLDER_TYPE_UNKNOWN => 'i', 43 | ), 44 | 'rootcreatefolderbackend' => 'i', 45 | ); 46 | } 47 | } 48 | 49 | ?> 50 | -------------------------------------------------------------------------------- /conf/zpush/backend_imap.php: -------------------------------------------------------------------------------- 1 | true))); 33 | define('IMAP_FROM_SQL_QUERY', "SELECT name, email FROM identities i INNER JOIN users u ON i.user_id = u.user_id WHERE u.username = '#username' AND i.standard = 1 AND i.del = 0 AND i.name <> ''"); 34 | define('IMAP_FROM_SQL_FIELDS', serialize(array('name', 'email'))); 35 | define('IMAP_FROM_SQL_FROM', '#name <#email>'); 36 | define('IMAP_FROM_SQL_FULLNAME', '#name'); 37 | 38 | // not used 39 | define('IMAP_FROM_LDAP_SERVER', ''); 40 | define('IMAP_FROM_LDAP_SERVER_PORT', '389'); 41 | define('IMAP_FROM_LDAP_USER', 'cn=zpush,ou=servers,dc=zpush,dc=org'); 42 | define('IMAP_FROM_LDAP_PASSWORD', 'password'); 43 | define('IMAP_FROM_LDAP_BASE', 'dc=zpush,dc=org'); 44 | define('IMAP_FROM_LDAP_QUERY', '(mail=#username@#domain)'); 45 | define('IMAP_FROM_LDAP_FIELDS', serialize(array('givenname', 'sn', 'mail'))); 46 | define('IMAP_FROM_LDAP_FROM', '#givenname #sn <#mail>'); 47 | define('IMAP_FROM_LDAP_FULLNAME', '#givenname #sn'); 48 | 49 | define('IMAP_SMTP_METHOD', 'sendmail'); 50 | 51 | global $imap_smtp_params; 52 | $imap_smtp_params = array('host' => 'ssl://127.0.0.1', 'port' => 465, 'auth' => true, 'username' => 'imap_username', 'password' => 'imap_password'); 53 | 54 | define('MAIL_MIMEPART_CRLF', "\r\n"); 55 | define('IMAP_MEETING_USE_CALDAV', true); 56 | 57 | ?> 58 | -------------------------------------------------------------------------------- /management/auth.py: -------------------------------------------------------------------------------- 1 | import base64, hmac, json, secrets 2 | from datetime import timedelta 3 | 4 | from expiringdict import ExpiringDict 5 | 6 | import utils 7 | from mailconfig import get_mail_password, get_mail_user_privileges 8 | from mfa import get_hash_mfa_state, validate_auth_mfa 9 | 10 | DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key' 11 | DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server' 12 | 13 | class AuthService: 14 | def __init__(self): 15 | self.auth_realm = DEFAULT_AUTH_REALM 16 | self.key_path = DEFAULT_KEY_PATH 17 | self.max_session_duration = timedelta(days=2) 18 | 19 | self.init_system_api_key() 20 | self.sessions = ExpiringDict(max_len=64, max_age_seconds=self.max_session_duration.total_seconds()) 21 | 22 | def init_system_api_key(self): 23 | """Write an API key to a local file so local processes can use the API""" 24 | 25 | with open(self.key_path, encoding='utf-8') as file: 26 | self.key = file.read() 27 | 28 | def authenticate(self, request, env, login_only=False, logout=False): 29 | """Test if the HTTP Authorization header's username matches the system key, a session key, 30 | or if the username/password passed in the header matches a local user. 31 | Returns a tuple of the user's email address and list of user privileges (e.g. 32 | ('my@email', []) or ('my@email', ['admin']); raises a ValueError on login failure. 33 | If the user used the system API key, the user's email is returned as None since 34 | this key is not associated with a user.""" 35 | 36 | def parse_http_authorization_basic(header): 37 | def decode(s): 38 | return base64.b64decode(s.encode('ascii')).decode('ascii') 39 | if " " not in header: 40 | return None, None 41 | scheme, credentials = header.split(maxsplit=1) 42 | if scheme != 'Basic': 43 | return None, None 44 | credentials = decode(credentials) 45 | if ":" not in credentials: 46 | return None, None 47 | username, password = credentials.split(':', maxsplit=1) 48 | return username, password 49 | 50 | username, password = parse_http_authorization_basic(request.headers.get('Authorization', '')) 51 | if username in {None, ""}: 52 | msg = "Authorization header invalid." 53 | raise ValueError(msg) 54 | 55 | if username.strip() == "" and password.strip() == "": 56 | msg = "No email address, password, session key, or API key provided." 57 | raise ValueError(msg) 58 | 59 | # If user passed the system API key, grant administrative privs. This key 60 | # is not associated with a user. 61 | if username == self.key and not login_only: 62 | return (None, ["admin"]) 63 | 64 | # If the password corresponds with a session token for the user, grant access for that user. 65 | if self.get_session(username, password, "login", env) and not login_only: 66 | sessionid = password 67 | session = self.sessions[sessionid] 68 | if logout: 69 | # Clear the session. 70 | del self.sessions[sessionid] 71 | else: 72 | # Re-up the session so that it does not expire. 73 | self.sessions[sessionid] = session 74 | 75 | # If no password was given, but a username was given, we're missing some information. 76 | elif password.strip() == "": 77 | msg = "Enter a password." 78 | raise ValueError(msg) 79 | 80 | else: 81 | # The user is trying to log in with a username and a password 82 | # (and possibly a MFA token). On failure, an exception is raised. 83 | self.check_user_auth(username, password, request, env) 84 | 85 | # Get privileges for authorization. This call should never fail because by this 86 | # point we know the email address is a valid user --- unless the user has been 87 | # deleted after the session was granted. On error the call will return a tuple 88 | # of an error message and an HTTP status code. 89 | privs = get_mail_user_privileges(username, env) 90 | if isinstance(privs, tuple): raise ValueError(privs[0]) 91 | 92 | # Return the authorization information. 93 | return (username, privs) 94 | 95 | def check_user_auth(self, email, pw, request, env): 96 | # Validate a user's login email address and password. If MFA is enabled, 97 | # check the MFA token in the X-Auth-Token header. 98 | # 99 | # On login failure, raises a ValueError with a login error message. On 100 | # success, nothing is returned. 101 | 102 | # Authenticate. 103 | try: 104 | # Get the hashed password of the user. Raise a ValueError if the 105 | # email address does not correspond to a user. But wrap it in the 106 | # same exception as if a password fails so we don't easily reveal 107 | # if an email address is valid. 108 | pw_hash = get_mail_password(email, env) 109 | 110 | # Use 'doveadm pw' to check credentials. doveadm will return 111 | # a non-zero exit status if the credentials are no good, 112 | # and check_call will raise an exception in that case. 113 | utils.shell('check_call', [ 114 | "/usr/bin/doveadm", "pw", 115 | "-p", pw, 116 | "-t", pw_hash, 117 | ]) 118 | except: 119 | # Login failed. 120 | msg = "Incorrect email address or password." 121 | raise ValueError(msg) 122 | 123 | # If MFA is enabled, check that MFA passes. 124 | status, hints = validate_auth_mfa(email, request, env) 125 | if not status: 126 | # Login valid. Hints may have more info. 127 | raise ValueError(",".join(hints)) 128 | 129 | def create_user_password_state_token(self, email, env): 130 | # Create a token that changes if the user's password or MFA options change 131 | # so that sessions become invalid if any of that information changes. 132 | msg = get_mail_password(email, env).encode("utf8") 133 | 134 | # Add to the message the current MFA state, which is a list of MFA information. 135 | # Turn it into a string stably. 136 | msg += b" " + json.dumps(get_hash_mfa_state(email, env), sort_keys=True).encode("utf8") 137 | 138 | # Make a HMAC using the system API key as a hash key. 139 | hash_key = self.key.encode('ascii') 140 | return hmac.new(hash_key, msg, digestmod="sha256").hexdigest() 141 | 142 | def create_session_key(self, username, env, type=None): 143 | # Create a new session. 144 | token = secrets.token_hex(32) 145 | self.sessions[token] = { 146 | "email": username, 147 | "password_token": self.create_user_password_state_token(username, env), 148 | "type": type, 149 | } 150 | return token 151 | 152 | def get_session(self, user_email, session_key, session_type, env): 153 | if session_key not in self.sessions: return None 154 | session = self.sessions[session_key] 155 | if session_type == "login" and session["email"] != user_email: return None 156 | if session["type"] != session_type: return None 157 | if session["password_token"] != self.create_user_password_state_token(session["email"], env): return None 158 | return session 159 | -------------------------------------------------------------------------------- /management/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # 3 | # This is a command-line script for calling management APIs 4 | # on the Mail-in-a-Box control panel backend. The script 5 | # reads /var/lib/mailinabox/api.key for the backend's 6 | # root API key. This file is readable only by root, so this 7 | # tool can only be used as root. 8 | 9 | import sys, getpass, urllib.request, urllib.error, json, csv 10 | import contextlib 11 | 12 | def mgmt(cmd, data=None, is_json=False): 13 | # The base URL for the management daemon. (Listens on IPv4 only.) 14 | mgmt_uri = 'http://127.0.0.1:10222' 15 | 16 | setup_key_auth(mgmt_uri) 17 | 18 | req = urllib.request.Request(mgmt_uri + cmd, urllib.parse.urlencode(data).encode("utf8") if data else None) 19 | try: 20 | response = urllib.request.urlopen(req) 21 | except urllib.error.HTTPError as e: 22 | if e.code == 401: 23 | with contextlib.suppress(Exception): 24 | print(e.read().decode("utf8")) 25 | print("The management daemon refused access. The API key file may be out of sync. Try 'service mailinabox restart'.", file=sys.stderr) 26 | elif hasattr(e, 'read'): 27 | print(e.read().decode('utf8'), file=sys.stderr) 28 | else: 29 | print(e, file=sys.stderr) 30 | sys.exit(1) 31 | resp = response.read().decode('utf8') 32 | if is_json: resp = json.loads(resp) 33 | return resp 34 | 35 | def read_password(): 36 | while True: 37 | first = getpass.getpass('password: ') 38 | if len(first) < 8: 39 | print("Passwords must be at least eight characters.") 40 | continue 41 | second = getpass.getpass(' (again): ') 42 | if first != second: 43 | print("Passwords not the same. Try again.") 44 | continue 45 | break 46 | return first 47 | 48 | def setup_key_auth(mgmt_uri): 49 | with open('/var/lib/mailinabox/api.key', encoding='utf-8') as f: 50 | key = f.read().strip() 51 | 52 | auth_handler = urllib.request.HTTPBasicAuthHandler() 53 | auth_handler.add_password( 54 | realm='Mail-in-a-Box Management Server', 55 | uri=mgmt_uri, 56 | user=key, 57 | passwd='') 58 | opener = urllib.request.build_opener(auth_handler) 59 | urllib.request.install_opener(opener) 60 | 61 | if len(sys.argv) < 2: 62 | print("""Usage: 63 | {cli} user (lists users) 64 | {cli} user add user@domain.com [password] 65 | {cli} user password user@domain.com [password] 66 | {cli} user remove user@domain.com 67 | {cli} user make-admin user@domain.com 68 | {cli} user quota user@domain [new-quota] (get or set user quota) 69 | {cli} user remove-admin user@domain.com 70 | {cli} user admins (lists admins) 71 | {cli} user mfa show user@domain.com (shows MFA devices for user, if any) 72 | {cli} user mfa disable user@domain.com [id] (disables MFA for user) 73 | {cli} alias (lists aliases) 74 | {cli} alias add incoming.name@domain.com sent.to@other.domain.com 75 | {cli} alias add incoming.name@domain.com 'sent.to@other.domain.com, multiple.people@other.domain.com' 76 | {cli} alias remove incoming.name@domain.com 77 | 78 | Removing a mail user does not delete their mail folders on disk. It only prevents IMAP/SMTP login. 79 | """.format( 80 | cli="management/cli.py" 81 | )) 82 | 83 | elif sys.argv[1] == "user" and len(sys.argv) == 2: 84 | # Dump a list of users, one per line. Mark admins with an asterisk. 85 | users = mgmt("/mail/users?format=json", is_json=True) 86 | for domain in users: 87 | for user in domain["users"]: 88 | if user['status'] == 'inactive': continue 89 | print(user['email'], end='') 90 | if "admin" in user['privileges']: 91 | print("*", end='') 92 | print() 93 | 94 | elif sys.argv[1] == "user" and sys.argv[2] in {"add", "password"}: 95 | if len(sys.argv) < 5: 96 | email = input('email: ') if len(sys.argv) < 4 else sys.argv[3] 97 | pw = read_password() 98 | else: 99 | email, pw = sys.argv[3:5] 100 | 101 | if sys.argv[2] == "add": 102 | print(mgmt("/mail/users/add", { "email": email, "password": pw })) 103 | elif sys.argv[2] == "password": 104 | print(mgmt("/mail/users/password", { "email": email, "password": pw })) 105 | 106 | elif sys.argv[1] == "user" and sys.argv[2] == "remove" and len(sys.argv) == 4: 107 | print(mgmt("/mail/users/remove", { "email": sys.argv[3] })) 108 | 109 | elif sys.argv[1] == "user" and sys.argv[2] in {"make-admin", "remove-admin"} and len(sys.argv) == 4: 110 | action = 'add' if sys.argv[2] == 'make-admin' else 'remove' 111 | print(mgmt("/mail/users/privileges/" + action, { "email": sys.argv[3], "privilege": "admin" })) 112 | 113 | elif sys.argv[1] == "user" and sys.argv[2] == "admins": 114 | # Dump a list of admin users. 115 | users = mgmt("/mail/users?format=json", is_json=True) 116 | for domain in users: 117 | for user in domain["users"]: 118 | if "admin" in user['privileges']: 119 | print(user['email']) 120 | 121 | elif sys.argv[1] == "user" and sys.argv[2] == "quota" and len(sys.argv) == 4: 122 | # Get a user's quota 123 | print(mgmt("/mail/users/quota?text=1&email=%s" % sys.argv[3])) 124 | 125 | elif sys.argv[1] == "user" and sys.argv[2] == "quota" and len(sys.argv) == 5: 126 | # Set a user's quota 127 | users = mgmt("/mail/users/quota", { "email": sys.argv[3], "quota": sys.argv[4] }) 128 | 129 | elif sys.argv[1] == "user" and len(sys.argv) == 5 and sys.argv[2:4] == ["mfa", "show"]: 130 | # Show MFA status for a user. 131 | status = mgmt("/mfa/status", { "user": sys.argv[4] }, is_json=True) 132 | W = csv.writer(sys.stdout) 133 | W.writerow(["id", "type", "label"]) 134 | for mfa in status["enabled_mfa"]: 135 | W.writerow([mfa["id"], mfa["type"], mfa["label"]]) 136 | 137 | elif sys.argv[1] == "user" and len(sys.argv) in {5, 6} and sys.argv[2:4] == ["mfa", "disable"]: 138 | # Disable MFA (all or a particular device) for a user. 139 | print(mgmt("/mfa/disable", { "user": sys.argv[4], "mfa-id": sys.argv[5] if len(sys.argv) == 6 else None })) 140 | 141 | elif sys.argv[1] == "alias" and len(sys.argv) == 2: 142 | print(mgmt("/mail/aliases")) 143 | 144 | elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5: 145 | print(mgmt("/mail/aliases/add", { "address": sys.argv[3], "forwards_to": sys.argv[4] })) 146 | 147 | elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4: 148 | print(mgmt("/mail/aliases/remove", { "address": sys.argv[3] })) 149 | 150 | else: 151 | print("Invalid command-line arguments.") 152 | sys.exit(1) 153 | -------------------------------------------------------------------------------- /management/csr_country_codes.tsv: -------------------------------------------------------------------------------- 1 | # This list is derived from https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2. 2 | # The columns are ISO_3166-1_alpha-2 code, display name, Wikipedia page name. 3 | # The top 21 countries by number of Internet users are grouped first, see 4 | # https://en.wikipedia.org/wiki/List_of_countries_by_number_of_Internet_users. 5 | CN China 6 | IN India 7 | US United States 8 | JP Japan 9 | BR Brazil 10 | RU Russian Federation Russia 11 | DE Germany 12 | NG Nigeria 13 | GB United Kingdom 14 | FR France 15 | MX Mexico 16 | EG Egypt 17 | KR South Korea 18 | VN Vietnam 19 | ID Indonesia 20 | PH Philippines 21 | TR Turkey 22 | IT Italy 23 | PK Pakistan 24 | ES Spain 25 | CA Canada 26 | AD Andorra 27 | AE United Arab Emirates 28 | AF Afghanistan 29 | AG Antigua and Barbuda 30 | AI Anguilla 31 | AL Albania 32 | AM Armenia 33 | AO Angola 34 | AQ Antarctica 35 | AR Argentina 36 | AS American Samoa 37 | AT Austria 38 | AU Australia 39 | AW Aruba 40 | AX Åland Islands 41 | AZ Azerbaijan 42 | BA Bosnia and Herzegovina 43 | BB Barbados 44 | BD Bangladesh 45 | BE Belgium 46 | BF Burkina Faso 47 | BG Bulgaria 48 | BH Bahrain 49 | BI Burundi 50 | BJ Benin 51 | BL Saint Barthélemy 52 | BM Bermuda 53 | BN Brunei 54 | BO Bolivia 55 | BQ Bonaire, Sint Eustatius and Saba Caribbean Netherlands 56 | BS Bahamas The Bahamas 57 | BT Bhutan 58 | BV Bouvet Island 59 | BW Botswana 60 | BY Belarus 61 | BZ Belize 62 | CC Cocos (Keeling) Islands 63 | CD Congo, the Democratic Republic of the Democratic Republic of the Congo 64 | CF Central African Republic 65 | CG Congo Republic of the Congo 66 | CH Switzerland 67 | CI Côte d'Ivoire 68 | CK Cook Islands 69 | CL Chile 70 | CM Cameroon 71 | CO Colombia 72 | CR Costa Rica 73 | CU Cuba 74 | CV Cabo Verde 75 | CW Curaçao 76 | CX Christmas Island 77 | CY Cyprus 78 | CZ Czech Republic 79 | DJ Djibouti 80 | DK Denmark 81 | DM Dominica 82 | DO Dominican Republic 83 | DZ Algeria 84 | EC Ecuador 85 | EE Estonia 86 | EH Western Sahara 87 | ER Eritrea 88 | ET Ethiopia 89 | FI Finland 90 | FJ Fiji 91 | FK Falkland Islands (Malvinas) Falkland Islands 92 | FM Federated States of Micronesia 93 | FO Faroe Islands 94 | GA Gabon 95 | GD Grenada 96 | GE Georgia Georgia (country) 97 | GF French Guiana 98 | GG Guernsey 99 | GH Ghana 100 | GI Gibraltar 101 | GL Greenland 102 | GM Gambia The Gambia 103 | GN Guinea 104 | GP Guadeloupe 105 | GQ Equatorial Guinea 106 | GR Greece 107 | GS South Georgia and the South Sandwich Islands 108 | GT Guatemala 109 | GU Guam 110 | GW Guinea-Bissau 111 | GY Guyana 112 | HK Hong Kong 113 | HM Heard Island and McDonald Islands 114 | HN Honduras 115 | HR Croatia 116 | HT Haiti 117 | HU Hungary 118 | IE Ireland Republic of Ireland 119 | IL Israel 120 | IM Isle of Man 121 | IO British Indian Ocean Territory 122 | IQ Iraq 123 | IR Iran 124 | IS Iceland 125 | JE Jersey 126 | JM Jamaica 127 | JO Jordan 128 | KE Kenya 129 | KG Kyrgyzstan 130 | KH Cambodia 131 | KI Kiribati 132 | KM Comoros 133 | KN Saint Kitts and Nevis 134 | KP North Korea 135 | KW Kuwait 136 | KY Cayman Islands 137 | KZ Kazakhstan 138 | LA Laos 139 | LB Lebanon 140 | LC Saint Lucia 141 | LI Liechtenstein 142 | LK Sri Lanka 143 | LR Liberia 144 | LS Lesotho 145 | LT Lithuania 146 | LU Luxembourg 147 | LV Latvia 148 | LY Libya 149 | MA Morocco 150 | MC Monaco 151 | MD Moldova 152 | ME Montenegro 153 | MF Saint Martin (French part) Collectivity of Saint Martin 154 | MG Madagascar 155 | MH Marshall Islands 156 | MK Macedonia Republic of Macedonia 157 | ML Mali 158 | MM Myanmar 159 | MN Mongolia 160 | MO Macao Macau 161 | MP Northern Mariana Islands 162 | MQ Martinique 163 | MR Mauritania 164 | MS Montserrat 165 | MT Malta 166 | MU Mauritius 167 | MV Maldives 168 | MW Malawi 169 | MY Malaysia 170 | MZ Mozambique 171 | NA Namibia 172 | NC New Caledonia 173 | NE Niger 174 | NF Norfolk Island 175 | NI Nicaragua 176 | NL Netherlands 177 | NO Norway 178 | NP Nepal 179 | NR Nauru 180 | NU Niue 181 | NZ New Zealand 182 | OM Oman 183 | PA Panama 184 | PE Peru 185 | PF French Polynesia 186 | PG Papua New Guinea 187 | PL Poland 188 | PM Saint Pierre and Miquelon 189 | PN Pitcairn Pitcairn Islands 190 | PR Puerto Rico 191 | PS Palestine State of Palestine 192 | PT Portugal 193 | PW Palau 194 | PY Paraguay 195 | QA Qatar 196 | RE Réunion 197 | RO Romania 198 | RS Serbia 199 | RW Rwanda 200 | SA Saudi Arabia 201 | SB Solomon Islands 202 | SC Seychelles 203 | SD Sudan 204 | SE Sweden 205 | SG Singapore 206 | SH Saint Helena, Ascension and Tristan da Cunha 207 | SI Slovenia 208 | SJ Svalbard and Jan Mayen 209 | SK Slovakia 210 | SL Sierra Leone 211 | SM San Marino 212 | SN Senegal 213 | SO Somalia 214 | SR Suriname 215 | SS South Sudan 216 | ST Sao Tome and Principe 217 | SV El Salvador 218 | SX Sint Maarten (Dutch part) Sint Maarten 219 | SY Syria 220 | SZ Swaziland 221 | TC Turks and Caicos Islands 222 | TD Chad 223 | TF French Southern Territories French Southern and Antarctic Lands 224 | TG Togo 225 | TH Thailand 226 | TJ Tajikistan 227 | TK Tokelau 228 | TL Timor-Leste East Timor 229 | TM Turkmenistan 230 | TN Tunisia 231 | TO Tonga 232 | TT Trinidad and Tobago 233 | TV Tuvalu 234 | TW Taiwan 235 | TZ Tanzania 236 | UA Ukraine 237 | UG Uganda 238 | UM United States Minor Outlying Islands 239 | UY Uruguay 240 | UZ Uzbekistan 241 | VA Vatican City 242 | VC Saint Vincent and the Grenadines 243 | VE Venezuela 244 | VG Virgin Islands, British British Virgin Islands 245 | VI Virgin Islands, U.S. United States Virgin Islands 246 | VU Vanuatu 247 | WF Wallis and Futuna 248 | WS Samoa 249 | YE Yemen 250 | YT Mayotte 251 | ZA South Africa 252 | ZM Zambia 253 | ZW Zimbabwe 254 | -------------------------------------------------------------------------------- /management/daily_tasks.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This script is run daily (at 3am each night). 3 | 4 | # Set character encoding flags to ensure that any non-ASCII 5 | # characters don't cause problems. See setup/start.sh and 6 | # the management daemon startup script. 7 | export LANGUAGE=en_US.UTF-8 8 | export LC_ALL=en_US.UTF-8 9 | export LANG=en_US.UTF-8 10 | export LC_TYPE=en_US.UTF-8 11 | 12 | # On Mondays, i.e. once a week, send the administrator a report of total emails 13 | # sent and received so the admin might notice server abuse. 14 | if [ "$(date "+%u")" -eq 1 ]; then 15 | management/mail_log.py -t week | management/email_administrator.py "Mail-in-a-Box Usage Report" 16 | fi 17 | 18 | # Take a backup. 19 | management/backup.py 2>&1 | management/email_administrator.py "Backup Status" 20 | 21 | # Provision any new certificates for new domains or domains with expiring certificates. 22 | management/ssl_certificates.py -q 2>&1 | management/email_administrator.py "TLS Certificate Provisioning Result" 23 | 24 | # Run status checks and email the administrator if anything changed. 25 | management/status_checks.py --show-changes 2>&1 | management/email_administrator.py "Status Checks Change Notice" 26 | -------------------------------------------------------------------------------- /management/email_administrator.py: -------------------------------------------------------------------------------- 1 | #!/usr/local/lib/mailinabox/env/bin/python 2 | 3 | # Reads in STDIN. If the stream is not empty, mail it to the system administrator. 4 | 5 | import sys 6 | 7 | import html 8 | import smtplib 9 | 10 | from email.mime.multipart import MIMEMultipart 11 | from email.mime.text import MIMEText 12 | 13 | # In Python 3.6: 14 | #from email.message import Message 15 | 16 | from utils import load_environment 17 | 18 | # Load system environment info. 19 | env = load_environment() 20 | 21 | # Process command line args. 22 | subject = sys.argv[1] 23 | 24 | # Administrator's email address. 25 | admin_addr = "administrator@" + env['PRIMARY_HOSTNAME'] 26 | 27 | # Read in STDIN. 28 | content = sys.stdin.read().strip() 29 | 30 | # If there's nothing coming in, just exit. 31 | if content == "": 32 | sys.exit(0) 33 | 34 | # create MIME message 35 | msg = MIMEMultipart('alternative') 36 | 37 | # In Python 3.6: 38 | #msg = Message() 39 | 40 | msg['From'] = '"{}" <{}>'.format(env['PRIMARY_HOSTNAME'], admin_addr) 41 | msg['To'] = admin_addr 42 | msg['Subject'] = "[{}] {}".format(env['PRIMARY_HOSTNAME'], subject) 43 | 44 | content_html = f'
{html.escape(content)}
' 45 | 46 | msg.attach(MIMEText(content, 'plain')) 47 | msg.attach(MIMEText(content_html, 'html')) 48 | 49 | # In Python 3.6: 50 | #msg.set_content(content) 51 | #msg.add_alternative(content_html, "html") 52 | 53 | # send 54 | smtpclient = smtplib.SMTP('127.0.0.1', 25) 55 | smtpclient.ehlo() 56 | smtpclient.sendmail( 57 | admin_addr, # MAIL FROM 58 | admin_addr, # RCPT TO 59 | msg.as_string()) 60 | smtpclient.quit() 61 | -------------------------------------------------------------------------------- /management/mfa.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hmac 3 | import io 4 | import os 5 | import pyotp 6 | import qrcode 7 | 8 | from mailconfig import open_database 9 | 10 | def get_user_id(email, c): 11 | c.execute('SELECT id FROM users WHERE email=?', (email,)) 12 | r = c.fetchone() 13 | if not r: raise ValueError("User does not exist.") 14 | return r[0] 15 | 16 | def get_mfa_state(email, env): 17 | c = open_database(env) 18 | c.execute('SELECT id, type, secret, mru_token, label FROM mfa WHERE user_id=?', (get_user_id(email, c),)) 19 | return [ 20 | { "id": r[0], "type": r[1], "secret": r[2], "mru_token": r[3], "label": r[4] } 21 | for r in c.fetchall() 22 | ] 23 | 24 | def get_public_mfa_state(email, env): 25 | mfa_state = get_mfa_state(email, env) 26 | return [ 27 | { "id": s["id"], "type": s["type"], "label": s["label"] } 28 | for s in mfa_state 29 | ] 30 | 31 | def get_hash_mfa_state(email, env): 32 | mfa_state = get_mfa_state(email, env) 33 | return [ 34 | { "id": s["id"], "type": s["type"], "secret": s["secret"] } 35 | for s in mfa_state 36 | ] 37 | 38 | def enable_mfa(email, type, secret, token, label, env): 39 | if type == "totp": 40 | validate_totp_secret(secret) 41 | # Sanity check with the provide current token. 42 | totp = pyotp.TOTP(secret) 43 | if not totp.verify(token, valid_window=1): 44 | msg = "Invalid token." 45 | raise ValueError(msg) 46 | else: 47 | msg = "Invalid MFA type." 48 | raise ValueError(msg) 49 | 50 | conn, c = open_database(env, with_connection=True) 51 | c.execute('INSERT INTO mfa (user_id, type, secret, label) VALUES (?, ?, ?, ?)', (get_user_id(email, c), type, secret, label)) 52 | conn.commit() 53 | 54 | def set_mru_token(email, mfa_id, token, env): 55 | conn, c = open_database(env, with_connection=True) 56 | c.execute('UPDATE mfa SET mru_token=? WHERE user_id=? AND id=?', (token, get_user_id(email, c), mfa_id)) 57 | conn.commit() 58 | 59 | def disable_mfa(email, mfa_id, env): 60 | conn, c = open_database(env, with_connection=True) 61 | if mfa_id is None: 62 | # Disable all MFA for a user. 63 | c.execute('DELETE FROM mfa WHERE user_id=?', (get_user_id(email, c),)) 64 | else: 65 | # Disable a particular MFA mode for a user. 66 | c.execute('DELETE FROM mfa WHERE user_id=? AND id=?', (get_user_id(email, c), mfa_id)) 67 | conn.commit() 68 | return c.rowcount > 0 69 | 70 | def validate_totp_secret(secret): 71 | if not isinstance(secret, str) or secret.strip() == "": 72 | msg = "No secret provided." 73 | raise ValueError(msg) 74 | if len(secret) != 32: 75 | msg = "Secret should be a 32 characters base32 string" 76 | raise ValueError(msg) 77 | 78 | def provision_totp(email, env): 79 | # Make a new secret. 80 | secret = base64.b32encode(os.urandom(20)).decode('utf-8') 81 | validate_totp_secret(secret) # sanity check 82 | 83 | # Make a URI that we encode within a QR code. 84 | uri = pyotp.TOTP(secret).provisioning_uri( 85 | name=email, 86 | issuer_name=env["PRIMARY_HOSTNAME"] + " Mail-in-a-Box Control Panel" 87 | ) 88 | 89 | # Generate a QR code as a base64-encode PNG image. 90 | qr = qrcode.make(uri) 91 | byte_arr = io.BytesIO() 92 | qr.save(byte_arr, format='PNG') 93 | png_b64 = base64.b64encode(byte_arr.getvalue()).decode('utf-8') 94 | 95 | return { 96 | "type": "totp", 97 | "secret": secret, 98 | "qr_code_base64": png_b64 99 | } 100 | 101 | def validate_auth_mfa(email, request, env): 102 | # Validates that a login request satisfies any MFA modes 103 | # that have been enabled for the user's account. Returns 104 | # a tuple (status, [hints]). status is True for a successful 105 | # MFA login, False for a missing token. If status is False, 106 | # hints is an array of codes that indicate what the user 107 | # can try. Possible codes are: 108 | # "missing-totp-token" 109 | # "invalid-totp-token" 110 | 111 | mfa_state = get_mfa_state(email, env) 112 | 113 | # If no MFA modes are added, return True. 114 | if len(mfa_state) == 0: 115 | return (True, []) 116 | 117 | # Try the enabled MFA modes. 118 | hints = set() 119 | for mfa_mode in mfa_state: 120 | if mfa_mode["type"] == "totp": 121 | # Check that a token is present in the X-Auth-Token header. 122 | # If not, give a hint that one can be supplied. 123 | token = request.headers.get('x-auth-token') 124 | if not token: 125 | hints.add("missing-totp-token") 126 | continue 127 | 128 | # Check for a replay attack. 129 | if hmac.compare_digest(token, mfa_mode['mru_token'] or ""): 130 | # If the token fails, skip this MFA mode. 131 | hints.add("invalid-totp-token") 132 | continue 133 | 134 | # Check the token. 135 | totp = pyotp.TOTP(mfa_mode["secret"]) 136 | if not totp.verify(token, valid_window=1): 137 | hints.add("invalid-totp-token") 138 | continue 139 | 140 | # On success, record the token to prevent a replay attack. 141 | set_mru_token(email, mfa_mode['id'], token, env) 142 | return (True, []) 143 | 144 | # On a failed login, indicate failure and any hints for what the user can do instead. 145 | return (False, list(hints)) 146 | -------------------------------------------------------------------------------- /management/munin_start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | mkdir -p /var/run/munin && chown munin /var/run/munin 3 | -------------------------------------------------------------------------------- /management/templates/external-dns.html: -------------------------------------------------------------------------------- 1 | 28 | 29 |

External DNS

30 | 31 |

This is an advanced configuration page.

32 | 33 |

Although your box is configured to serve its own DNS, it is possible to host your DNS elsewhere — such as in the DNS control panel provided by your domain name registrar or virtual cloud provider — by copying the DNS zone information shown in the table below into your external DNS server’s control panel.

34 | 35 |

If you do so, you are responsible for keeping your DNS entries up to date! If you previously enabled DNSSEC on your domain name by setting a DS record at your registrar, you will likely have to turn it off before changing nameservers.

36 | 37 | 38 | 44 | 45 |

Download zonefile

46 |

You can download your zonefiles here or use the table of records below.

47 |
48 |
49 |
50 | 51 | 52 |
53 | 54 |
55 |
56 | 57 |

Records

58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 |
QNameTypeValue
70 | 71 | 128 | -------------------------------------------------------------------------------- /management/templates/login.html: -------------------------------------------------------------------------------- 1 | 25 | 26 |

{{hostname}}

27 | 28 | {% if no_users_exist or no_admins_exist %} 29 |
30 |
31 | {% if no_users_exist %} 32 |

There are no users on this system! To make an administrative user, 33 | log into this machine using SSH (like when you first set it up) and run:

34 |
cd mailinabox
 35 | sudo management/cli.py user add me@{{hostname}}
 36 | sudo management/cli.py user make-admin me@{{hostname}}
37 | {% else %} 38 |

There are no administrative users on this system! To make an administrative user, 39 | log into this machine using SSH (like when you first set it up) and run:

40 |
cd mailinabox
 41 | sudo management/cli.py user make-admin me@{{hostname}}
42 | {% endif %} 43 |
44 |
45 |
46 | {% endif %} 47 | 48 |

Log in here for your Mail-in-a-Box control panel.

49 | 50 | 87 | 88 | 218 | -------------------------------------------------------------------------------- /management/templates/mail-guide.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |

Checking and Sending Mail

5 | 6 |
7 |
8 |

Webmail

9 | 10 |

Webmail lets you check your email from any web browser. Your webmail site is:

11 |

https://{{hostname}}/mail

12 |

Your username is your whole email address.

13 | 14 | 15 |

Mobile/desktop apps

16 | 17 |

Automatic configuration

18 | 19 |

iOS and macOS only: Open this configuration link on your iOS device or on your Mac desktop to easily set up mail (IMAP/SMTP), Contacts, and Calendar. Your username is your whole email address.

20 | 21 |

Manual configuration

22 | 23 |

Use the following settings when you set up your email on your phone, desktop, or other device:

24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 |
Option Value
Protocol/Method IMAP
Mail server {{hostname}}
IMAP Port 993
IMAP Security SSL or TLS
SMTP Port 465
SMTP Security SSL or TLS
Username: Your whole email address.
Password: Your mail password.
38 | 39 |

In addition to setting up your email, you’ll also need to set up contacts and calendar synchronization separately.

40 | 41 |

As an alternative to IMAP you can also use the POP protocol: choose POP as the protocol, port 995, and SSL or TLS security in your mail client. The SMTP settings and usernames and passwords remain the same. However, we recommend you use IMAP instead.

42 | 43 |

Exchange/ActiveSync settings

44 | 45 |

On iOS devices, devices on this compatibility list, or using Outlook 2007 or later on Windows 7 and later, you may set up your mail as an Exchange or ActiveSync server. However, we’ve found this to be more buggy than using IMAP as described above. If you encounter any problems, please use the manual settings above.

46 | 47 | 48 | 49 | 50 |
Server {{hostname}}
Options Secure Connection
51 | 52 |

Your device should also provide a contacts list and calendar that syncs to this box when you use this method.

53 |
54 | 55 |
56 |
57 |
58 |

Other information about mail on your box

59 |
60 |
61 |

Greylisting

62 |

Your box uses a technique called greylisting to cut down on spam. Greylisting works by initially rejecting mail from people you haven’t received mail from before. Legitimate mail servers will attempt redelivery shortly afterwards, but the vast majority of spam gets tricked by this. If you are waiting for an email from someone new, such as if you are registering on a new website and are waiting for an email confirmation, please be aware there will be a minimum of 3 minutes delay, depending how soon the remote server attempts redelivery.

63 | 64 |

+tag addresses

65 |

Every incoming email address also receives mail for +tag addresses. If your email address is you@yourdomain.com, you’ll also automatically get mail sent to you+anythinghere@yourdomain.com. Use this as a fast way to segment incoming mail for your own filtering rules without having to create aliases in this control panel.

66 | 67 |

Use only this box to send as you

68 |

Your box sets strict email sending policies for your domain names to make it harder for spam and other fraudulent mail to claim to be you. Only this machine is authorized to send email on behalf of your domain names. If you use any other service to send email as you, it will likely get spam filtered by recipients.

69 |
70 |
71 |
72 |
73 |
74 | -------------------------------------------------------------------------------- /management/templates/munin.html: -------------------------------------------------------------------------------- 1 |

Munin Monitoring

2 | 3 | 5 | 6 |

Opening munin in a new tab... You may need to allow pop-ups for this site.

7 | 8 | 21 | -------------------------------------------------------------------------------- /management/templates/sync-guide.html: -------------------------------------------------------------------------------- 1 |
2 |

Contacts & Calendar Synchronization

3 | 4 |

This box can hold your contacts and calendar, just like it holds your email.

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

In your browser

11 | 12 |

You can edit your contacts and calendar from your web browser.

13 | 14 | 15 | 16 | 17 | 18 |
For... Visit this URL
Contacts https://{{hostname}}/cloud/contacts
Calendar https://{{hostname}}/cloud/calendar
19 | 20 |

Log in settings are the same as with mail: your 21 | complete email address and your mail password.

22 |
23 | 24 |
25 |

On your mobile device

26 | 27 |

If you set up your mail using Exchange/ActiveSync, 28 | your contacts and calendar may already appear on your device.

29 |

Otherwise, here are some apps that can synchronize your contacts and calendar to your Android phone.

30 | 31 | 32 | 33 | 34 | 35 | 36 |
For... Use...
Contacts and Calendar DAVx⁵ ($5.99; free here)
Only Contacts CardDAV-Sync free (free)
Only Calendar CalDAV-Sync ($2.99)
37 | 38 |

Use the following settings:

39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
Account Type CardDAV or CalDAV
Server Name {{hostname}}
Use SSL Yes
Username Your complete email address.
Password Your mail password.
47 |
48 |
49 |
50 | -------------------------------------------------------------------------------- /management/templates/system-status.html: -------------------------------------------------------------------------------- 1 |

System Status Checks

2 | 3 | 38 | 39 |
40 |
41 | 42 | 46 | 47 | 51 | 52 |
53 |
54 | 55 |
56 |
57 | 58 | 59 | 60 | 61 | 62 | 63 |
64 | 65 |
66 |
67 | 68 | 185 | -------------------------------------------------------------------------------- /management/templates/web.html: -------------------------------------------------------------------------------- 1 | 3 | 4 |

Static Web Hosting

5 | 6 |

This machine is serving a simple, static website at https://{{hostname}} and at all domain names that you set up an email user or alias for.

7 | 8 |

Uploading web files

9 | 10 |

You can replace the default website with your own HTML pages and other static files. This control panel won’t help you design a website, but once you have .html files you can upload them following these instructions:

11 | 12 |
    13 |
  1. Ensure that any domains you are publishing a website for have no problems on the Status Checks page.
  2. 14 | 15 |
  3. On your personal computer, install an SSH file transfer program such as FileZilla or scp.
  4. 16 | 17 |
  5. Log in to this machine with the file transfer program. The server is {{hostname}}, the protocol is SSH or SFTP, and use the SSH login credentials that you used when you originally created this machine at your cloud host provider. This is not what you use to log in either for email or this control panel. Your SSH credentials probably involves a private key file.
  6. 18 | 19 |
  7. Upload your .html or other files to the directory {{storage_root}}/www/default on this machine. They will appear directly and immediately on the web.
  8. 20 | 21 |
  9. The websites set up on this machine are listed in the table below with where to put the files for each website.
  10. 22 | 23 | 24 | 25 | 26 | 27 | 28 | 30 | 31 | 32 | 33 |
    SiteDirectory for Files 29 |
    34 | 35 |

    To add a domain to this table, create a dummy mail user or alias on the domain first and see the setup guide for adding nameserver records to the new domain at your registrar (but not glue records).

    36 | 37 |
38 | 39 | 90 | -------------------------------------------------------------------------------- /management/templates/welcome.html: -------------------------------------------------------------------------------- 1 | 12 | 13 |

{{hostname}}

14 | 15 |

Welcome to your Mail-in-a-Box control panel.

16 | 17 | -------------------------------------------------------------------------------- /management/utils.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | # DO NOT import non-standard modules. This module is imported by 4 | # migrate.py which runs on fresh machines before anything is installed 5 | # besides Python. 6 | 7 | # THE ENVIRONMENT FILE AT /etc/mailinabox.conf 8 | 9 | def load_environment(): 10 | # Load settings from /etc/mailinabox.conf. 11 | return load_env_vars_from_file("/etc/mailinabox.conf") 12 | 13 | def load_env_vars_from_file(fn): 14 | # Load settings from a KEY=VALUE file. 15 | import collections 16 | env = collections.OrderedDict() 17 | with open(fn, encoding="utf-8") as f: 18 | for line in f: 19 | env.setdefault(*line.strip().split("=", 1)) 20 | return env 21 | 22 | def save_environment(env): 23 | with open("/etc/mailinabox.conf", "w", encoding="utf-8") as f: 24 | for k, v in env.items(): 25 | f.write(f"{k}={v}\n") 26 | 27 | # THE SETTINGS FILE AT STORAGE_ROOT/settings.yaml. 28 | 29 | def write_settings(config, env): 30 | import rtyaml 31 | fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml') 32 | with open(fn, "w", encoding="utf-8") as f: 33 | f.write(rtyaml.dump(config)) 34 | 35 | def load_settings(env): 36 | import rtyaml 37 | fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml') 38 | try: 39 | with open(fn, encoding="utf-8") as f: 40 | config = rtyaml.load(f) 41 | if not isinstance(config, dict): raise ValueError # caught below 42 | return config 43 | except: 44 | return { } 45 | 46 | # UTILITIES 47 | 48 | def safe_domain_name(name): 49 | # Sanitize a domain name so it is safe to use as a file name on disk. 50 | import urllib.parse 51 | return urllib.parse.quote(name, safe='') 52 | 53 | def sort_domains(domain_names, env): 54 | # Put domain names in a nice sorted order. 55 | 56 | # The nice order will group domain names by DNS zone, i.e. the top-most 57 | # domain name that we serve that ecompasses a set of subdomains. Map 58 | # each of the domain names to the zone that contains them. Walk the domains 59 | # from shortest to longest since zones are always shorter than their 60 | # subdomains. 61 | zones = { } 62 | for domain in sorted(domain_names, key=len): 63 | for z in zones.values(): 64 | if domain.endswith("." + z): 65 | # We found a parent domain already in the list. 66 | zones[domain] = z 67 | break 68 | else: 69 | # 'break' did not occur: there is no parent domain, so it is its 70 | # own zone. 71 | zones[domain] = domain 72 | 73 | # Sort the zones. 74 | zone_domains = sorted(zones.values(), 75 | key = lambda d : ( 76 | # PRIMARY_HOSTNAME or the zone that contains it is always first. 77 | not (d == env['PRIMARY_HOSTNAME'] or env['PRIMARY_HOSTNAME'].endswith("." + d)), 78 | 79 | # Then just dumb lexicographically. 80 | d, 81 | )) 82 | 83 | # Now sort the domain names that fall within each zone. 84 | return sorted(domain_names, 85 | key = lambda d : ( 86 | # First by zone. 87 | zone_domains.index(zones[d]), 88 | 89 | # PRIMARY_HOSTNAME is always first within the zone that contains it. 90 | d != env['PRIMARY_HOSTNAME'], 91 | 92 | # Followed by any of its subdomains. 93 | not d.endswith("." + env['PRIMARY_HOSTNAME']), 94 | 95 | # Then in right-to-left lexicographic order of the .-separated parts of the name. 96 | list(reversed(d.split("."))), 97 | )) 98 | 99 | 100 | def sort_email_addresses(email_addresses, env): 101 | email_addresses = set(email_addresses) 102 | domains = {email.split("@", 1)[1] for email in email_addresses if "@" in email} 103 | ret = [] 104 | for domain in sort_domains(domains, env): 105 | domain_emails = {email for email in email_addresses if email.endswith("@" + domain)} 106 | ret.extend(sorted(domain_emails)) 107 | email_addresses -= domain_emails 108 | ret.extend(sorted(email_addresses)) # whatever is left 109 | return ret 110 | 111 | def shell(method, cmd_args, env=None, capture_stderr=False, return_bytes=False, trap=False, input=None): 112 | # A safe way to execute processes. 113 | # Some processes like apt-get require being given a sane PATH. 114 | import subprocess 115 | 116 | if env is None: 117 | env = {} 118 | env.update({ "PATH": "/sbin:/bin:/usr/sbin:/usr/bin" }) 119 | kwargs = { 120 | 'env': env, 121 | 'stderr': None if not capture_stderr else subprocess.STDOUT, 122 | } 123 | if method == "check_output" and input is not None: 124 | kwargs['input'] = input 125 | 126 | if not trap: 127 | ret = getattr(subprocess, method)(cmd_args, **kwargs) 128 | else: 129 | try: 130 | ret = getattr(subprocess, method)(cmd_args, **kwargs) 131 | code = 0 132 | except subprocess.CalledProcessError as e: 133 | ret = e.output 134 | code = e.returncode 135 | if not return_bytes and isinstance(ret, bytes): ret = ret.decode("utf8") 136 | if not trap: 137 | return ret 138 | else: 139 | return code, ret 140 | 141 | def create_syslog_handler(): 142 | import logging.handlers 143 | handler = logging.handlers.SysLogHandler(address='/dev/log') 144 | handler.setLevel(logging.WARNING) 145 | return handler 146 | 147 | def du(path): 148 | # Computes the size of all files in the path, like the `du` command. 149 | # Based on http://stackoverflow.com/a/17936789. Takes into account 150 | # soft and hard links. 151 | total_size = 0 152 | seen = set() 153 | for dirpath, _dirnames, filenames in os.walk(path): 154 | for f in filenames: 155 | fp = os.path.join(dirpath, f) 156 | try: 157 | stat = os.lstat(fp) 158 | except OSError: 159 | continue 160 | if stat.st_ino in seen: 161 | continue 162 | seen.add(stat.st_ino) 163 | total_size += stat.st_size 164 | return total_size 165 | 166 | def wait_for_service(port, public, env, timeout): 167 | # Block until a service on a given port (bound privately or publicly) 168 | # is taking connections, with a maximum timeout. 169 | import socket, time 170 | start = time.perf_counter() 171 | while True: 172 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 173 | s.settimeout(timeout/3) 174 | try: 175 | s.connect(("127.0.0.1" if not public else env['PUBLIC_IP'], port)) 176 | return True 177 | except OSError: 178 | if time.perf_counter() > start+timeout: 179 | return False 180 | time.sleep(min(timeout/4, 1)) 181 | 182 | def get_ssh_port(): 183 | port_value = get_ssh_config_value("port") 184 | 185 | if port_value: 186 | return int(port_value) 187 | 188 | return None 189 | 190 | def get_ssh_config_value(parameter_name): 191 | # Returns ssh configuration value for the provided parameter 192 | import subprocess 193 | try: 194 | output = shell('check_output', ['sshd', '-T']) 195 | except FileNotFoundError: 196 | # sshd is not installed. That's ok. 197 | return None 198 | except subprocess.CalledProcessError: 199 | # error while calling shell command 200 | return None 201 | 202 | for line in output.split("\n"): 203 | if " " not in line: continue # there's a blank line at the end 204 | key, values = line.split(" ", 1) 205 | if key == parameter_name: 206 | return values # space-delimited if there are multiple values 207 | 208 | # Did not find the parameter! 209 | return None 210 | 211 | if __name__ == "__main__": 212 | from web_update import get_web_domains 213 | env = load_environment() 214 | domains = get_web_domains(env) 215 | for domain in domains: 216 | print(domain) 217 | -------------------------------------------------------------------------------- /management/wsgi.py: -------------------------------------------------------------------------------- 1 | from daemon import app 2 | import utils 3 | 4 | app.logger.addHandler(utils.create_syslog_handler()) 5 | 6 | if __name__ == "__main__": 7 | app.run(port=10222) 8 | -------------------------------------------------------------------------------- /setup/bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | ######################################################### 3 | # This script is intended to be run like this: 4 | # 5 | # curl https://mailinabox.email/setup.sh | sudo bash 6 | # 7 | ######################################################### 8 | 9 | if [ -z "$TAG" ]; then 10 | # If a version to install isn't explicitly given as an environment 11 | # variable, then install the latest version. But the latest version 12 | # depends on the machine's version of Ubuntu. Existing users need to 13 | # be able to upgrade to the latest version available for that version 14 | # of Ubuntu to satisfy the migration requirements. 15 | # 16 | # Also, the system status checks read this script for TAG = (without the 17 | # space, but if we put it in a comment it would confuse the status checks!) 18 | # to get the latest version, so the first such line must be the one that we 19 | # want to display in status checks. 20 | # 21 | # Allow point-release versions of the major releases, e.g. 22.04.1 is OK. 22 | UBUNTU_VERSION=$( lsb_release -d | sed 's/.*:\s*//' | sed 's/\([0-9]*\.[0-9]*\)\.[0-9]/\1/' ) 23 | if [ "$UBUNTU_VERSION" == "Ubuntu 22.04 LTS" ]; then 24 | # This machine is running Ubuntu 22.04, which is supported by 25 | # Mail-in-a-Box versions 60 and later. 26 | TAG=v71a 27 | elif [ "$UBUNTU_VERSION" == "Ubuntu 18.04 LTS" ]; then 28 | # This machine is running Ubuntu 18.04, which is supported by 29 | # Mail-in-a-Box versions 0.40 through 5x. 30 | echo "Support is ending for Ubuntu 18.04." 31 | echo "Please immediately begin to migrate your data to" 32 | echo "a new machine running Ubuntu 22.04. See:" 33 | echo "https://mailinabox.email/maintenance.html#upgrade" 34 | TAG=v57a 35 | elif [ "$UBUNTU_VERSION" == "Ubuntu 14.04 LTS" ]; then 36 | # This machine is running Ubuntu 14.04, which is supported by 37 | # Mail-in-a-Box versions 1 through v0.30. 38 | echo "Ubuntu 14.04 is no longer supported." 39 | echo "The last version of Mail-in-a-Box supporting Ubuntu 14.04 will be installed." 40 | TAG=v0.30 41 | else 42 | echo "This script may be used only on a machine running Ubuntu 14.04, 18.04, or 22.04." 43 | exit 1 44 | fi 45 | fi 46 | 47 | # Are we running as root? 48 | if [[ $EUID -ne 0 ]]; then 49 | echo "This script must be run as root. Did you leave out sudo?" 50 | exit 1 51 | fi 52 | 53 | # Clone the Mail-in-a-Box repository if it doesn't exist. 54 | if [ ! -d "$HOME/mailinabox" ]; then 55 | if [ ! -f /usr/bin/git ]; then 56 | echo "Installing git . . ." 57 | apt-get -q -q update 58 | DEBIAN_FRONTEND=noninteractive apt-get -q -q install -y git < /dev/null 59 | echo 60 | fi 61 | 62 | if [ "$SOURCE" == "" ]; then 63 | SOURCE=https://github.com/mail-in-a-box/mailinabox 64 | fi 65 | 66 | echo "Downloading Mail-in-a-Box $TAG. . ." 67 | git clone \ 68 | -b "$TAG" --depth 1 \ 69 | "$SOURCE" \ 70 | "$HOME/mailinabox" \ 71 | < /dev/null 2> /dev/null 72 | 73 | echo 74 | fi 75 | 76 | # Change directory to it. 77 | cd "$HOME/mailinabox" || exit 78 | 79 | # Update it. 80 | if [ "$TAG" != "$(git describe --always)" ]; then 81 | echo "Updating Mail-in-a-Box to $TAG . . ." 82 | git fetch --depth 1 --force --prune origin tag "$TAG" 83 | if ! git checkout -q "$TAG"; then 84 | echo "Update failed. Did you modify something in $PWD?" 85 | exit 1 86 | fi 87 | echo 88 | fi 89 | 90 | # Start setup script. 91 | setup/start.sh 92 | -------------------------------------------------------------------------------- /setup/dkim.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # OpenDKIM 3 | # -------- 4 | # 5 | # OpenDKIM provides a service that puts a DKIM signature on outbound mail. 6 | # 7 | # The DNS configuration for DKIM is done in the management daemon. 8 | 9 | source setup/functions.sh # load our functions 10 | source /etc/mailinabox.conf # load global vars 11 | 12 | # Install DKIM... 13 | echo "Installing OpenDKIM/OpenDMARC..." 14 | apt_install opendkim opendkim-tools opendmarc 15 | 16 | # Make sure configuration directories exist. 17 | mkdir -p /etc/opendkim; 18 | mkdir -p "$STORAGE_ROOT/mail/dkim" 19 | 20 | # Used in InternalHosts and ExternalIgnoreList configuration directives. 21 | # Not quite sure why. 22 | echo "127.0.0.1" > /etc/opendkim/TrustedHosts 23 | 24 | # We need to at least create these files, since we reference them later. 25 | # Otherwise, opendkim startup will fail 26 | touch /etc/opendkim/KeyTable 27 | touch /etc/opendkim/SigningTable 28 | 29 | if grep -q "ExternalIgnoreList" /etc/opendkim.conf; then 30 | true # already done #NODOC 31 | else 32 | # Add various configuration options to the end of `opendkim.conf`. 33 | cat >> /etc/opendkim.conf << EOF; 34 | Canonicalization relaxed/simple 35 | MinimumKeyBits 1024 36 | ExternalIgnoreList refile:/etc/opendkim/TrustedHosts 37 | InternalHosts refile:/etc/opendkim/TrustedHosts 38 | KeyTable refile:/etc/opendkim/KeyTable 39 | SigningTable refile:/etc/opendkim/SigningTable 40 | Socket inet:8891@127.0.0.1 41 | RequireSafeKeys false 42 | EOF 43 | fi 44 | 45 | # Create a new DKIM key. This creates mail.private and mail.txt 46 | # in $STORAGE_ROOT/mail/dkim. The former is the private key and 47 | # the latter is the suggested DNS TXT entry which we'll include 48 | # in our DNS setup. Note that the files are named after the 49 | # 'selector' of the key, which we can change later on to support 50 | # key rotation. 51 | # 52 | # A 1024-bit key is seen as a minimum standard by several providers 53 | # such as Google. But they and others use a 2048 bit key, so we'll 54 | # do the same. Keys beyond 2048 bits may exceed DNS record limits. 55 | if [ ! -f "$STORAGE_ROOT/mail/dkim/mail.private" ]; then 56 | opendkim-genkey -b 2048 -r -s mail -D "$STORAGE_ROOT/mail/dkim" 57 | fi 58 | 59 | # Ensure files are owned by the opendkim user and are private otherwise. 60 | chown -R opendkim:opendkim "$STORAGE_ROOT/mail/dkim" 61 | chmod go-rwx "$STORAGE_ROOT/mail/dkim" 62 | 63 | tools/editconf.py /etc/opendmarc.conf -s \ 64 | "Syslog=true" \ 65 | "Socket=inet:8893@[127.0.0.1]" \ 66 | "FailureReports=false" 67 | 68 | # SPFIgnoreResults causes the filter to ignore any SPF results in the header 69 | # of the message. This is useful if you want the filter to perform SPF checks 70 | # itself, or because you don't trust the arriving header. This added header is 71 | # used by spamassassin to evaluate the mail for spamminess. 72 | 73 | tools/editconf.py /etc/opendmarc.conf -s \ 74 | "SPFIgnoreResults=true" 75 | 76 | # SPFSelfValidate causes the filter to perform a fallback SPF check itself 77 | # when it can find no SPF results in the message header. If SPFIgnoreResults 78 | # is also set, it never looks for SPF results in headers and always performs 79 | # the SPF check itself when this is set. This added header is used by 80 | # spamassassin to evaluate the mail for spamminess. 81 | 82 | tools/editconf.py /etc/opendmarc.conf -s \ 83 | "SPFSelfValidate=true" 84 | 85 | # Disables generation of failure reports for sending domains that publish a 86 | # "none" policy. 87 | 88 | tools/editconf.py /etc/opendmarc.conf -s \ 89 | "FailureReportsOnNone=false" 90 | 91 | # AlwaysAddARHeader Adds an "Authentication-Results:" header field even to 92 | # unsigned messages from domains with no "signs all" policy. The reported DKIM 93 | # result will be "none" in such cases. Normally unsigned mail from non-strict 94 | # domains does not cause the results header field to be added. This added header 95 | # is used by spamassassin to evaluate the mail for spamminess. 96 | 97 | tools/editconf.py /etc/opendkim.conf -s \ 98 | "AlwaysAddARHeader=true" 99 | 100 | # Add OpenDKIM and OpenDMARC as milters to postfix, which is how OpenDKIM 101 | # intercepts outgoing mail to perform the signing (by adding a mail header) 102 | # and how they both intercept incoming mail to add Authentication-Results 103 | # headers. The order possibly/probably matters: OpenDMARC relies on the 104 | # OpenDKIM Authentication-Results header already being present. 105 | # 106 | # Be careful. If we add other milters later, this needs to be concatenated 107 | # on the smtpd_milters line. 108 | # 109 | # The OpenDMARC milter is skipped in the SMTP submission listener by 110 | # configuring smtpd_milters there to only list the OpenDKIM milter 111 | # (see mail-postfix.sh). 112 | tools/editconf.py /etc/postfix/main.cf \ 113 | "smtpd_milters=inet:127.0.0.1:8891 inet:127.0.0.1:8893"\ 114 | non_smtpd_milters=\$smtpd_milters \ 115 | milter_default_action=accept 116 | 117 | # We need to explicitly enable the opendmarc service, or it will not start 118 | hide_output systemctl enable opendmarc 119 | 120 | # Restart services. 121 | restart_service opendkim 122 | restart_service opendmarc 123 | restart_service postfix 124 | 125 | -------------------------------------------------------------------------------- /setup/dns.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # DNS 3 | # ----------------------------------------------- 4 | 5 | # This script installs packages, but the DNS zone files are only 6 | # created by the /dns/update API in the management server because 7 | # the set of zones (domains) hosted by the server depends on the 8 | # mail users & aliases created by the user later. 9 | 10 | source setup/functions.sh # load our functions 11 | source /etc/mailinabox.conf # load global vars 12 | 13 | # Prepare nsd's configuration. 14 | # We configure nsd before installation as we only want it to bind to some addresses 15 | # and it otherwise will have port / bind conflicts with bind9 used as the local resolver 16 | mkdir -p /var/run/nsd 17 | mkdir -p /etc/nsd 18 | mkdir -p /etc/nsd/zones 19 | touch /etc/nsd/zones.conf 20 | 21 | cat > /etc/nsd/nsd.conf << EOF; 22 | # Do not edit. Overwritten by Mail-in-a-Box setup. 23 | server: 24 | hide-version: yes 25 | logfile: "/var/log/nsd.log" 26 | 27 | # identify the server (CH TXT ID.SERVER entry). 28 | identity: "" 29 | 30 | # The directory for zonefile: files. 31 | zonesdir: "/etc/nsd/zones" 32 | 33 | # Allows NSD to bind to IP addresses that are not (yet) added to the 34 | # network interface. This allows nsd to start even if the network stack 35 | # isn't fully ready, which apparently happens in some cases. 36 | # See https://www.nlnetlabs.nl/projects/nsd/nsd.conf.5.html. 37 | ip-transparent: yes 38 | 39 | EOF 40 | 41 | # Since we have bind9 listening on localhost for locally-generated 42 | # DNS queries that require a recursive nameserver, and the system 43 | # might have other network interfaces for e.g. tunnelling, we have 44 | # to be specific about the network interfaces that nsd binds to. 45 | for ip in $PRIVATE_IP $PRIVATE_IPV6; do 46 | echo " ip-address: $ip" >> /etc/nsd/nsd.conf; 47 | done 48 | 49 | # Create a directory for additional configuration directives, including 50 | # the zones.conf file written out by our management daemon. 51 | echo "include: /etc/nsd/nsd.conf.d/*.conf" >> /etc/nsd/nsd.conf; 52 | 53 | # Remove the old location of zones.conf that we generate. It will 54 | # now be stored in /etc/nsd/nsd.conf.d. 55 | rm -f /etc/nsd/zones.conf 56 | 57 | # Add log rotation 58 | cat > /etc/logrotate.d/nsd < "$STORAGE_ROOT/dns/dnssec/$algo.conf" << EOF; 130 | KSK=$KSK 131 | ZSK=$ZSK 132 | EOF 133 | fi 134 | 135 | # And loop to do the next algorithm... 136 | done 137 | 138 | # Force the dns_update script to be run every day to re-sign zones for DNSSEC 139 | # before they expire. When we sign zones (in `dns_update.py`) we specify a 140 | # 30-day validation window, so we had better re-sign before then. 141 | cat > /etc/cron.daily/mailinabox-dnssec << EOF; 142 | #!/bin/bash 143 | # Mail-in-a-Box 144 | # Re-sign any DNS zones with DNSSEC because the signatures expire periodically. 145 | $PWD/tools/dns_update 146 | EOF 147 | chmod +x /etc/cron.daily/mailinabox-dnssec 148 | 149 | # Permit DNS queries on TCP/UDP in the firewall. 150 | 151 | ufw_allow domain 152 | 153 | -------------------------------------------------------------------------------- /setup/firstuser.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # If there aren't any mail users yet, create one. 3 | if [ -z "$(management/cli.py user)" ]; then 4 | # The output of "management/cli.py user" is a list of mail users. If there 5 | # aren't any yet, it'll be empty. 6 | 7 | # If we didn't ask for an email address at the start, do so now. 8 | if [ -z "${EMAIL_ADDR:-}" ]; then 9 | # In an interactive shell, ask the user for an email address. 10 | if [ -z "${NONINTERACTIVE:-}" ]; then 11 | input_box "Mail Account" \ 12 | "Let's create your first mail account. 13 | \n\nWhat email address do you want?" \ 14 | "me@$(get_default_hostname)" \ 15 | EMAIL_ADDR 16 | 17 | if [ -z "$EMAIL_ADDR" ]; then 18 | # user hit ESC/cancel 19 | exit 20 | fi 21 | while ! management/mailconfig.py validate-email "$EMAIL_ADDR" 22 | do 23 | input_box "Mail Account" \ 24 | "That's not a valid email address. 25 | \n\nWhat email address do you want?" \ 26 | "$EMAIL_ADDR" \ 27 | EMAIL_ADDR 28 | if [ -z "$EMAIL_ADDR" ]; then 29 | # user hit ESC/cancel 30 | exit 31 | fi 32 | done 33 | 34 | # But in a non-interactive shell, just make something up. 35 | # This is normally for testing. 36 | else 37 | # Use me@PRIMARY_HOSTNAME 38 | EMAIL_ADDR=me@$PRIMARY_HOSTNAME 39 | EMAIL_PW=12345678 40 | echo 41 | echo "Creating a new administrative mail account for $EMAIL_ADDR with password $EMAIL_PW." 42 | echo 43 | fi 44 | else 45 | echo 46 | echo "Okay. I'm about to set up $EMAIL_ADDR for you. This account will also" 47 | echo "have access to the box's control panel." 48 | fi 49 | 50 | # Create the user's mail account. This will ask for a password if none was given above. 51 | management/cli.py user add "$EMAIL_ADDR" ${EMAIL_PW:+"$EMAIL_PW"} 52 | 53 | # Make it an admin. 54 | hide_output management/cli.py user make-admin "$EMAIL_ADDR" 55 | 56 | # Create an alias to which we'll direct all automatically-created administrative aliases. 57 | management/cli.py alias add "administrator@$PRIMARY_HOSTNAME" "$EMAIL_ADDR" > /dev/null 58 | fi 59 | -------------------------------------------------------------------------------- /setup/mail-users.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # User Authentication and Destination Validation 4 | # ---------------------------------------------- 5 | # 6 | # This script configures user authentication for Dovecot 7 | # and Postfix (which relies on Dovecot) and destination 8 | # validation by querying an Sqlite3 database of mail users. 9 | 10 | source setup/functions.sh # load our functions 11 | source /etc/mailinabox.conf # load global vars 12 | 13 | # ### User and Alias Database 14 | 15 | # The database of mail users (i.e. authenticated users, who have mailboxes) 16 | # and aliases (forwarders). 17 | 18 | db_path=$STORAGE_ROOT/mail/users.sqlite 19 | 20 | # Create an empty database if it doesn't yet exist. 21 | if [ ! -f "$db_path" ]; then 22 | echo "Creating new user database: $db_path"; 23 | echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '', quota TEXT NOT NULL DEFAULT '0');" | sqlite3 "$db_path"; 24 | echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 "$db_path"; 25 | echo "CREATE TABLE mfa (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, type TEXT NOT NULL, secret TEXT NOT NULL, mru_token TEXT, label TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);" | sqlite3 "$db_path"; 26 | echo "CREATE TABLE auto_aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 "$db_path"; 27 | fi 28 | 29 | # ### User Authentication 30 | 31 | # Have Dovecot query our database, and not system users, for authentication. 32 | sed -i "s/#*\(\!include auth-system.conf.ext\)/#\1/" /etc/dovecot/conf.d/10-auth.conf 33 | sed -i "s/#\(\!include auth-sql.conf.ext\)/\1/" /etc/dovecot/conf.d/10-auth.conf 34 | 35 | # Specify how the database is to be queried for user authentication (passdb) 36 | # and where user mailboxes are stored (userdb). 37 | cat > /etc/dovecot/conf.d/auth-sql.conf.ext << EOF; 38 | passdb { 39 | driver = sql 40 | args = /etc/dovecot/dovecot-sql.conf.ext 41 | } 42 | userdb { 43 | driver = sql 44 | args = /etc/dovecot/dovecot-sql.conf.ext 45 | } 46 | EOF 47 | 48 | # Configure the SQL to query for a user's metadata and password. 49 | cat > /etc/dovecot/dovecot-sql.conf.ext << EOF; 50 | driver = sqlite 51 | connect = $db_path 52 | default_pass_scheme = SHA512-CRYPT 53 | password_query = SELECT email as user, password FROM users WHERE email='%u'; 54 | user_query = SELECT email AS user, "mail" as uid, "mail" as gid, "$STORAGE_ROOT/mail/mailboxes/%d/%n" as home, '*:bytes=' || quota AS quota_rule FROM users WHERE email='%u'; 55 | iterate_query = SELECT email AS user FROM users; 56 | EOF 57 | chmod 0600 /etc/dovecot/dovecot-sql.conf.ext # per Dovecot instructions 58 | 59 | # Have Dovecot provide an authorization service that Postfix can access & use. 60 | cat > /etc/dovecot/conf.d/99-local-auth.conf << EOF; 61 | service auth { 62 | unix_listener /var/spool/postfix/private/auth { 63 | mode = 0666 64 | user = postfix 65 | group = postfix 66 | } 67 | } 68 | EOF 69 | 70 | # And have Postfix use that service. We *disable* it here 71 | # so that authentication is not permitted on port 25 (which 72 | # does not run DKIM on relayed mail, so outbound mail isn't 73 | # correct, see #830), but we enable it specifically for the 74 | # submission port. 75 | tools/editconf.py /etc/postfix/main.cf \ 76 | smtpd_sasl_type=dovecot \ 77 | smtpd_sasl_path=private/auth \ 78 | smtpd_sasl_auth_enable=no 79 | 80 | # ### Sender Validation 81 | 82 | # We use Postfix's reject_authenticated_sender_login_mismatch filter to 83 | # prevent intra-domain spoofing by logged in but untrusted users in outbound 84 | # email. In all outbound mail (the sender has authenticated), the MAIL FROM 85 | # address (aka envelope or return path address) must be "owned" by the user 86 | # who authenticated. An SQL query will find who are the owners of any given 87 | # address. 88 | tools/editconf.py /etc/postfix/main.cf \ 89 | smtpd_sender_login_maps=sqlite:/etc/postfix/sender-login-maps.cf 90 | 91 | # Postfix will query the exact address first, where the priority will be alias 92 | # records first, then user records. If there are no matches for the exact 93 | # address, then Postfix will query just the domain part, which we call 94 | # catch-alls and domain aliases. A NULL permitted_senders column means to 95 | # take the value from the destination column. 96 | cat > /etc/postfix/sender-login-maps.cf << EOF; 97 | dbpath=$db_path 98 | query = SELECT permitted_senders FROM (SELECT permitted_senders, 0 AS priority FROM aliases WHERE source='%s' AND permitted_senders IS NOT NULL UNION SELECT destination AS permitted_senders, 1 AS priority FROM aliases WHERE source='%s' AND permitted_senders IS NULL UNION SELECT email as permitted_senders, 2 AS priority FROM users WHERE email='%s') ORDER BY priority LIMIT 1; 99 | EOF 100 | 101 | # ### Destination Validation 102 | 103 | # Use a Sqlite3 database to check whether a destination email address exists, 104 | # and to perform any email alias rewrites in Postfix. Additionally, we disable 105 | # SMTPUTF8 because Dovecot's LMTP server that delivers mail to inboxes does 106 | # not support it, and if a message is received with the SMTPUTF8 flag it will 107 | # bounce. 108 | tools/editconf.py /etc/postfix/main.cf \ 109 | smtputf8_enable=no \ 110 | virtual_mailbox_domains=sqlite:/etc/postfix/virtual-mailbox-domains.cf \ 111 | virtual_mailbox_maps=sqlite:/etc/postfix/virtual-mailbox-maps.cf \ 112 | virtual_alias_maps=sqlite:/etc/postfix/virtual-alias-maps.cf \ 113 | local_recipient_maps=\$virtual_mailbox_maps 114 | 115 | # SQL statement to check if we handle incoming mail for a domain, either for users or aliases. 116 | cat > /etc/postfix/virtual-mailbox-domains.cf << EOF; 117 | dbpath=$db_path 118 | query = SELECT 1 FROM users WHERE email LIKE '%%@%s' UNION SELECT 1 FROM aliases WHERE source LIKE '%%@%s' UNION SELECT 1 FROM auto_aliases WHERE source LIKE '%%@%s' 119 | EOF 120 | 121 | # SQL statement to check if we handle incoming mail for a user. 122 | cat > /etc/postfix/virtual-mailbox-maps.cf << EOF; 123 | dbpath=$db_path 124 | query = SELECT 1 FROM users WHERE email='%s' 125 | EOF 126 | 127 | # SQL statement to rewrite an email address if an alias is present. 128 | # 129 | # Postfix makes multiple queries for each incoming mail. It first 130 | # queries the whole email address, then just the user part in certain 131 | # locally-directed cases (but we don't use this), then just `@`+the 132 | # domain part. The first query that returns something wins. See 133 | # http://www.postfix.org/virtual.5.html. 134 | # 135 | # virtual-alias-maps has precedence over virtual-mailbox-maps, but 136 | # we don't want catch-alls and domain aliases to catch mail for users 137 | # that have been defined on those domains. To fix this, we not only 138 | # query the aliases table but also the users table when resolving 139 | # aliases, i.e. we turn users into aliases from themselves to 140 | # themselves. That means users will match in postfix's first query 141 | # before postfix gets to the third query for catch-alls/domain alises. 142 | # 143 | # If there is both an alias and a user for the same address either 144 | # might be returned by the UNION, so the whole query is wrapped in 145 | # another select that prioritizes the alias definition to preserve 146 | # postfix's preference for aliases for whole email addresses. 147 | # 148 | # Since we might have alias records with an empty destination because 149 | # it might have just permitted_senders, skip any records with an 150 | # empty destination here so that other lower priority rules might match. 151 | cat > /etc/postfix/virtual-alias-maps.cf << EOF; 152 | dbpath=$db_path 153 | query = SELECT destination from (SELECT destination, 0 as priority FROM aliases WHERE source='%s' AND destination<>'' UNION SELECT email as destination, 1 as priority FROM users WHERE email='%s' UNION SELECT destination, 2 as priority FROM auto_aliases WHERE source='%s' AND destination<>'') ORDER BY priority LIMIT 1; 154 | EOF 155 | 156 | # Restart Services 157 | ################## 158 | 159 | restart_service postfix 160 | restart_service dovecot 161 | 162 | # force a recalculation of all user quotas 163 | doveadm quota recalc -A 164 | -------------------------------------------------------------------------------- /setup/management.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | source setup/functions.sh 4 | source /etc/mailinabox.conf # load global vars 5 | 6 | echo "Installing Mail-in-a-Box system management daemon..." 7 | 8 | # DEPENDENCIES 9 | 10 | # duplicity is used to make backups of user data. 11 | # 12 | # virtualenv is used to isolate the Python 3 packages we 13 | # install via pip from the system-installed packages. 14 | # 15 | # certbot installs EFF's certbot which we use to 16 | # provision free TLS certificates. 17 | apt_install duplicity python3-pip virtualenv certbot rsync 18 | 19 | # b2sdk is used for backblaze backups. 20 | # boto3 is used for amazon aws backups. 21 | # Both are installed outside the pipenv, so they can be used by duplicity 22 | hide_output pip3 install --upgrade b2sdk boto3 23 | 24 | # Create a virtualenv for the installation of Python 3 packages 25 | # used by the management daemon. 26 | inst_dir=/usr/local/lib/mailinabox 27 | mkdir -p $inst_dir 28 | venv=$inst_dir/env 29 | if [ ! -d $venv ]; then 30 | # A bug specific to Ubuntu 22.04 and Python 3.10 requires 31 | # forcing a virtualenv directory layout option (see #2335 32 | # and https://github.com/pypa/virtualenv/pull/2415). In 33 | # our issue, reportedly installing python3-distutils didn't 34 | # fix the problem.) 35 | export DEB_PYTHON_INSTALL_LAYOUT='deb' 36 | hide_output virtualenv -ppython3 $venv 37 | fi 38 | 39 | # Upgrade pip because the Ubuntu-packaged version is out of date. 40 | hide_output $venv/bin/pip install --upgrade pip 41 | 42 | # Install other Python 3 packages used by the management daemon. 43 | # The first line is the packages that Josh maintains himself! 44 | # NOTE: email_validator is repeated in setup/questions.sh, so please keep the versions synced. 45 | hide_output $venv/bin/pip install --upgrade \ 46 | rtyaml "email_validator>=1.0.0" "exclusiveprocess" \ 47 | flask dnspython python-dateutil expiringdict gunicorn \ 48 | qrcode[pil] pyotp \ 49 | "idna>=2.0.0" "cryptography==37.0.2" psutil postfix-mta-sts-resolver \ 50 | b2sdk boto3 51 | 52 | # CONFIGURATION 53 | 54 | # Create a backup directory and a random key for encrypting backups. 55 | mkdir -p "$STORAGE_ROOT/backup" 56 | if [ ! -f "$STORAGE_ROOT/backup/secret_key.txt" ]; then 57 | (umask 077; openssl rand -base64 2048 > "$STORAGE_ROOT/backup/secret_key.txt") 58 | fi 59 | 60 | 61 | # Download jQuery and Bootstrap local files 62 | 63 | # Make sure we have the directory to save to. 64 | assets_dir=$inst_dir/vendor/assets 65 | rm -rf $assets_dir 66 | mkdir -p $assets_dir 67 | 68 | # jQuery CDN URL 69 | jquery_version=2.2.4 70 | jquery_url=https://code.jquery.com 71 | 72 | # Get jQuery 73 | wget_verify $jquery_url/jquery-$jquery_version.min.js 69bb69e25ca7d5ef0935317584e6153f3fd9a88c $assets_dir/jquery.min.js 74 | 75 | # Bootstrap CDN URL 76 | bootstrap_version=3.4.1 77 | bootstrap_url=https://github.com/twbs/bootstrap/releases/download/v$bootstrap_version/bootstrap-$bootstrap_version-dist.zip 78 | 79 | # Get Bootstrap 80 | wget_verify $bootstrap_url 0bb64c67c2552014d48ab4db81c2e8c01781f580 /tmp/bootstrap.zip 81 | unzip -q /tmp/bootstrap.zip -d $assets_dir 82 | mv $assets_dir/bootstrap-$bootstrap_version-dist $assets_dir/bootstrap 83 | rm -f /tmp/bootstrap.zip 84 | 85 | # Create an init script to start the management daemon and keep it 86 | # running after a reboot. 87 | # Set a long timeout since some commands take a while to run, matching 88 | # the timeout we set for PHP (fastcgi_read_timeout in the nginx confs). 89 | # Note: Authentication currently breaks with more than 1 gunicorn worker. 90 | cat > $inst_dir/start < /var/lib/mailinabox/api.key 100 | chmod 640 /var/lib/mailinabox/api.key 101 | 102 | source $venv/bin/activate 103 | export PYTHONPATH=$PWD/management 104 | exec gunicorn -b localhost:10222 -w 1 --timeout 630 wsgi:app 105 | EOF 106 | chmod +x $inst_dir/start 107 | cp --remove-destination conf/mailinabox.service /lib/systemd/system/mailinabox.service # target was previously a symlink so remove it first 108 | hide_output systemctl link -f /lib/systemd/system/mailinabox.service 109 | hide_output systemctl daemon-reload 110 | hide_output systemctl enable mailinabox.service 111 | 112 | # Perform nightly tasks at 3am in system time: take a backup, run 113 | # status checks and email the administrator any changes. 114 | 115 | minute=$((RANDOM % 60)) # avoid overloading mailinabox.email 116 | cat > /etc/cron.d/mailinabox-nightly << EOF; 117 | # Mail-in-a-Box --- Do not edit / will be overwritten on update. 118 | # Run nightly tasks: backup, status checks. 119 | $minute 1 * * * root (cd $PWD && management/daily_tasks.sh) 120 | EOF 121 | 122 | # Start the management server. 123 | restart_service mailinabox 124 | -------------------------------------------------------------------------------- /setup/munin.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Munin: resource monitoring tool 3 | ################################################# 4 | 5 | source setup/functions.sh # load our functions 6 | source /etc/mailinabox.conf # load global vars 7 | 8 | # install Munin 9 | echo "Installing Munin (system monitoring)..." 10 | apt_install munin munin-node libcgi-fast-perl 11 | # libcgi-fast-perl is needed by /usr/lib/munin/cgi/munin-cgi-graph 12 | 13 | # edit config 14 | cat > /etc/munin/munin.conf </dev/null | sh || /bin/true 48 | 49 | # Deactivate monitoring of NTP peers. Not sure why anyone would want to monitor a NTP peer. The addresses seem to change 50 | # (which is taken care of my munin-node-configure, but only when we re-run it.) 51 | find /etc/munin/plugins/ -lname /usr/share/munin/plugins/ntp_ -print0 | xargs -0 /bin/rm -f 52 | 53 | # Deactivate monitoring of network interfaces that are not up. Otherwise we can get a lot of empty charts. 54 | for f in $(find /etc/munin/plugins/ \( -lname /usr/share/munin/plugins/if_ -o -lname /usr/share/munin/plugins/if_err_ -o -lname /usr/share/munin/plugins/bonding_err_ \)); do 55 | IF=$(echo "$f" | sed s/.*_//); 56 | if ! grep -qFx up "/sys/class/net/$IF/operstate" 2>/dev/null; then 57 | rm "$f"; 58 | fi; 59 | done 60 | 61 | # Create a 'state' directory. Not sure why we need to do this manually. 62 | mkdir -p /var/lib/munin-node/plugin-state/ 63 | 64 | # Create a systemd service for munin. 65 | ln -sf "$PWD/management/munin_start.sh" /usr/local/lib/mailinabox/munin_start.sh 66 | chmod 0744 /usr/local/lib/mailinabox/munin_start.sh 67 | cp --remove-destination conf/munin.service /lib/systemd/system/munin.service # target was previously a symlink so remove first 68 | hide_output systemctl link -f /lib/systemd/system/munin.service 69 | hide_output systemctl daemon-reload 70 | hide_output systemctl unmask munin.service 71 | hide_output systemctl enable munin.service 72 | 73 | # Restart services. 74 | restart_service munin 75 | restart_service munin-node 76 | 77 | # generate initial statistics so the directory isn't empty 78 | # (We get "Pango-WARNING **: error opening config file '/root/.config/pango/pangorc': Permission denied" 79 | # if we don't explicitly set the HOME directory when sudo'ing.) 80 | # We check to see if munin-cron is already running, if it is, there is no need to run it simultaneously 81 | # generating an error. 82 | if [ ! -f /var/run/munin/munin-update.lock ]; then 83 | sudo -H -u munin munin-cron 84 | fi 85 | -------------------------------------------------------------------------------- /setup/network-checks.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Install the 'host', 'sed', and and 'nc' tools. This script is run before 3 | # the rest of the system setup so we may not yet have things installed. 4 | apt_get_quiet install bind9-host sed netcat-openbsd 5 | 6 | # Stop if the PRIMARY_HOSTNAME is listed in the Spamhaus Domain Block List. 7 | # The user might have chosen a name that was previously in use by a spammer 8 | # and will not be able to reliably send mail. Do this after any automatic 9 | # choices made above. 10 | if host "$PRIMARY_HOSTNAME.dbl.spamhaus.org" > /dev/null; then 11 | echo 12 | echo "The hostname you chose '$PRIMARY_HOSTNAME' is listed in the" 13 | echo "Spamhaus Domain Block List. See http://www.spamhaus.org/dbl/" 14 | echo "and http://www.spamhaus.org/query/domain/$PRIMARY_HOSTNAME." 15 | echo 16 | echo "You will not be able to send mail using this domain name, so" 17 | echo "setup cannot continue." 18 | echo 19 | exit 1 20 | fi 21 | 22 | # Stop if the IPv4 address is listed in the ZEN Spamhouse Block List. 23 | # The user might have ended up on an IP address that was previously in use 24 | # by a spammer, or the user may be deploying on a residential network. We 25 | # will not be able to reliably send mail in these cases. 26 | REVERSED_IPV4=$(echo "$PUBLIC_IP" | sed "s/\([0-9]*\).\([0-9]*\).\([0-9]*\).\([0-9]*\)/\4.\3.\2.\1/") 27 | if host "$REVERSED_IPV4.zen.spamhaus.org" > /dev/null; then 28 | echo 29 | echo "The IP address $PUBLIC_IP is listed in the Spamhaus Block List." 30 | echo "See http://www.spamhaus.org/query/ip/$PUBLIC_IP." 31 | echo 32 | echo "You will not be able to send mail using this machine, so setup" 33 | echo "cannot continue." 34 | echo 35 | echo "Associate a different IP address with this machine if possible." 36 | echo "Many residential network IP addresses are listed, so Mail-in-a-Box" 37 | echo "typically cannot be used on a residential Internet connection." 38 | echo 39 | exit 1 40 | fi 41 | 42 | # Stop if we cannot make an outbound connection on port 25. Many residential 43 | # networks block outbound port 25 to prevent their network from sending spam. 44 | # See if we can reach one of Google's MTAs with a 5-second timeout. 45 | if ! nc -z -w5 aspmx.l.google.com 25; then 46 | echo 47 | echo "Outbound mail (port 25) seems to be blocked by your network." 48 | echo 49 | echo "You will not be able to send mail using this machine, so setup" 50 | echo "cannot continue." 51 | echo 52 | echo "Many residential networks block port 25 to prevent hijacked" 53 | echo "machines from being able to send spam. I just tried to connect" 54 | echo "to Google's mail server on port 25 but the connection did not" 55 | echo "succeed." 56 | echo 57 | exit 1 58 | fi 59 | -------------------------------------------------------------------------------- /setup/preflight.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Are we running as root? 3 | if [[ $EUID -ne 0 ]]; then 4 | echo "This script must be run as root. Please re-run like this:" 5 | echo 6 | echo "sudo $0" 7 | echo 8 | exit 1 9 | fi 10 | 11 | # Check that we are running on Ubuntu 22.04 LTS (or 22.04.xx). 12 | # Pull in the variables defined in /etc/os-release but in a 13 | # namespace to avoid polluting our variables. 14 | source <(cat /etc/os-release | sed s/^/OS_RELEASE_/) 15 | if [ "${OS_RELEASE_ID:-}" != "ubuntu" ] || [ "${OS_RELEASE_VERSION_ID:-}" != "22.04" ]; then 16 | echo "Mail-in-a-Box only supports being installed on Ubuntu 22.04, sorry. You are running:" 17 | echo 18 | echo "${OS_RELEASE_ID:-"Unknown linux distribution"} ${OS_RELEASE_VERSION_ID:-}" 19 | echo 20 | echo "We can't write scripts that run on every possible setup, sorry." 21 | exit 1 22 | fi 23 | 24 | # Check that we have enough memory. 25 | # 26 | # /proc/meminfo reports free memory in kibibytes. Our baseline will be 512 MB, 27 | # which is 500000 kibibytes. 28 | # 29 | # We will display a warning if the memory is below 768 MB which is 750000 kibibytes 30 | # 31 | # Skip the check if we appear to be running inside of Vagrant, because that's really just for testing. 32 | TOTAL_PHYSICAL_MEM=$(head -n 1 /proc/meminfo | awk '{print $2}') 33 | if [ "$TOTAL_PHYSICAL_MEM" -lt 490000 ]; then 34 | if [ ! -d /vagrant ]; then 35 | TOTAL_PHYSICAL_MEM=$(( TOTAL_PHYSICAL_MEM * 1024 / 1000 / 1000 )) 36 | echo "Your Mail-in-a-Box needs more memory (RAM) to function properly." 37 | echo "Please provision a machine with at least 512 MB, 1 GB recommended." 38 | echo "This machine has $TOTAL_PHYSICAL_MEM MB memory." 39 | exit 40 | fi 41 | fi 42 | if [ "$TOTAL_PHYSICAL_MEM" -lt 750000 ]; then 43 | echo "WARNING: Your Mail-in-a-Box has less than 768 MB of memory." 44 | echo " It might run unreliably when under heavy load." 45 | fi 46 | 47 | # Check that tempfs is mounted with exec 48 | MOUNTED_TMP_AS_NO_EXEC=$(grep "/tmp.*noexec" /proc/mounts || /bin/true) 49 | if [ -n "$MOUNTED_TMP_AS_NO_EXEC" ]; then 50 | echo "Mail-in-a-Box has to have exec rights on /tmp, please mount /tmp with exec" 51 | exit 52 | fi 53 | 54 | # Check that no .wgetrc exists 55 | if [ -e ~/.wgetrc ]; then 56 | echo "Mail-in-a-Box expects no overrides to wget defaults, ~/.wgetrc exists" 57 | exit 58 | fi 59 | 60 | # Check that we are running on x86_64 or i686 architecture, which are the only 61 | # ones we support / test. 62 | ARCHITECTURE=$(uname -m) 63 | if [ "$ARCHITECTURE" != "x86_64" ] && [ "$ARCHITECTURE" != "i686" ]; then 64 | echo 65 | echo "WARNING:" 66 | echo "Mail-in-a-Box has only been tested on x86_64 and i686 platform" 67 | echo "architectures. Your architecture, $ARCHITECTURE, may not work." 68 | echo "You are on your own." 69 | echo 70 | fi 71 | -------------------------------------------------------------------------------- /setup/ssl.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # RSA private key, SSL certificate, Diffie-Hellman bits files 4 | # ------------------------------------------- 5 | 6 | # Create an RSA private key, a self-signed SSL certificate, and some 7 | # Diffie-Hellman cipher bits, if they have not yet been created. 8 | # 9 | # The RSA private key and certificate are used for: 10 | # 11 | # * DNSSEC DANE TLSA records 12 | # * IMAP 13 | # * SMTP (opportunistic TLS for port 25 and submission on ports 465/587) 14 | # * HTTPS 15 | # 16 | # The certificate is created with its CN set to the PRIMARY_HOSTNAME. It is 17 | # also used for other domains served over HTTPS until the user installs a 18 | # better certificate for those domains. 19 | # 20 | # The Diffie-Hellman cipher bits are used for SMTP and HTTPS, when a 21 | # Diffie-Hellman cipher is selected during TLS negotiation. Diffie-Hellman 22 | # provides Perfect Forward Secrecy. 23 | 24 | source setup/functions.sh # load our functions 25 | source /etc/mailinabox.conf # load global vars 26 | 27 | # Show a status line if we are going to take any action in this file. 28 | if [ ! -f /usr/bin/openssl ] \ 29 | || [ ! -f "$STORAGE_ROOT/ssl/ssl_private_key.pem" ] \ 30 | || [ ! -f "$STORAGE_ROOT/ssl/ssl_certificate.pem" ] \ 31 | || [ ! -f "$STORAGE_ROOT/ssl/dh2048.pem" ]; then 32 | echo "Creating initial SSL certificate and perfect forward secrecy Diffie-Hellman parameters..." 33 | fi 34 | 35 | # Install openssl. 36 | 37 | apt_install openssl 38 | 39 | # Create a directory to store TLS-related things like "SSL" certificates. 40 | 41 | mkdir -p "$STORAGE_ROOT/ssl" 42 | 43 | # Generate a new private key. 44 | # 45 | # The key is only as good as the entropy available to openssl so that it 46 | # can generate a random key. "OpenSSL’s built-in RSA key generator .... 47 | # is seeded on first use with (on Linux) 32 bytes read from /dev/urandom, 48 | # the process ID, user ID, and the current time in seconds. [During key 49 | # generation OpenSSL] mixes into the entropy pool the current time in seconds, 50 | # the process ID, and the possibly uninitialized contents of a ... buffer 51 | # ... dozens to hundreds of times." 52 | # 53 | # A perfect storm of issues can cause the generated key to be not very random: 54 | # 55 | # * improperly seeded /dev/urandom, but see system.sh for how we mitigate this 56 | # * the user ID of this process is always the same (we're root), so that seed is useless 57 | # * zero'd memory (plausible on embedded systems, cloud VMs?) 58 | # * a predictable process ID (likely on an embedded/virtualized system) 59 | # * a system clock reset to a fixed time on boot 60 | # 61 | # Since we properly seed /dev/urandom in system.sh we should be fine, but I leave 62 | # in the rest of the notes in case that ever changes. 63 | if [ ! -f "$STORAGE_ROOT/ssl/ssl_private_key.pem" ]; then 64 | # Set the umask so the key file is never world-readable. 65 | (umask 077; hide_output \ 66 | openssl genrsa -out "$STORAGE_ROOT/ssl/ssl_private_key.pem" 2048) 67 | fi 68 | 69 | # Generate a self-signed SSL certificate because things like nginx, dovecot, 70 | # etc. won't even start without some certificate in place, and we need nginx 71 | # so we can offer the user a control panel to install a better certificate. 72 | if [ ! -f "$STORAGE_ROOT/ssl/ssl_certificate.pem" ]; then 73 | # Generate a certificate signing request. 74 | CSR=/tmp/ssl_cert_sign_req-$$.csr 75 | hide_output \ 76 | openssl req -new -key "$STORAGE_ROOT/ssl/ssl_private_key.pem" -out $CSR \ 77 | -sha256 -subj "/CN=$PRIMARY_HOSTNAME" 78 | 79 | # Generate the self-signed certificate. 80 | CERT=$STORAGE_ROOT/ssl/$PRIMARY_HOSTNAME-selfsigned-$(date --rfc-3339=date | sed s/-//g).pem 81 | hide_output \ 82 | openssl x509 -req -days 365 \ 83 | -in $CSR -signkey "$STORAGE_ROOT/ssl/ssl_private_key.pem" -out "$CERT" 84 | 85 | # Delete the certificate signing request because it has no other purpose. 86 | rm -f $CSR 87 | 88 | # Symlink the certificate into the system certificate path, so system services 89 | # can find it. 90 | ln -s "$CERT" "$STORAGE_ROOT/ssl/ssl_certificate.pem" 91 | fi 92 | 93 | # Generate some Diffie-Hellman cipher bits. 94 | # openssl's default bit length for this is 1024 bits, but we'll create 95 | # 2048 bits of bits per the latest recommendations. 96 | if [ ! -f "$STORAGE_ROOT/ssl/dh2048.pem" ]; then 97 | openssl dhparam -out "$STORAGE_ROOT/ssl/dh2048.pem" 2048 98 | fi 99 | 100 | # Cleanup expired SSL certificates from $STORAGE_ROOT/ssl daily 101 | cat > /etc/cron.daily/mailinabox-ssl-cleanup << EOF; 102 | #!/bin/bash 103 | # Mail-in-a-Box 104 | # Cleanup expired SSL certificates 105 | $(pwd)/tools/ssl_cleanup 106 | EOF 107 | chmod +x /etc/cron.daily/mailinabox-ssl-cleanup 108 | -------------------------------------------------------------------------------- /setup/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This is the entry point for configuring the system. 3 | ##################################################### 4 | 5 | source setup/functions.sh # load our functions 6 | 7 | # Check system setup: Are we running as root on Ubuntu 18.04 on a 8 | # machine with enough memory? Is /tmp mounted with exec. 9 | # If not, this shows an error and exits. 10 | source setup/preflight.sh 11 | 12 | # Ensure Python reads/writes files in UTF-8. If the machine 13 | # triggers some other locale in Python, like ASCII encoding, 14 | # Python may not be able to read/write files. This is also 15 | # in the management daemon startup script and the cron script. 16 | 17 | if ! locale -a | grep en_US.utf8 > /dev/null; then 18 | # Generate locale if not exists 19 | hide_output locale-gen en_US.UTF-8 20 | fi 21 | 22 | export LANGUAGE=en_US.UTF-8 23 | export LC_ALL=en_US.UTF-8 24 | export LANG=en_US.UTF-8 25 | export LC_TYPE=en_US.UTF-8 26 | 27 | # Fix so line drawing characters are shown correctly in Putty on Windows. See #744. 28 | export NCURSES_NO_UTF8_ACS=1 29 | 30 | # Recall the last settings used if we're running this a second time. 31 | if [ -f /etc/mailinabox.conf ]; then 32 | # Run any system migrations before proceeding. Since this is a second run, 33 | # we assume we have Python already installed. 34 | setup/migrate.py --migrate || exit 1 35 | 36 | # Load the old .conf file to get existing configuration options loaded 37 | # into variables with a DEFAULT_ prefix. 38 | cat /etc/mailinabox.conf | sed s/^/DEFAULT_/ > /tmp/mailinabox.prev.conf 39 | source /tmp/mailinabox.prev.conf 40 | rm -f /tmp/mailinabox.prev.conf 41 | else 42 | FIRST_TIME_SETUP=1 43 | fi 44 | 45 | # Put a start script in a global location. We tell the user to run 'mailinabox' 46 | # in the first dialog prompt, so we should do this before that starts. 47 | cat > /usr/local/bin/mailinabox << EOF; 48 | #!/bin/bash 49 | cd $PWD 50 | source setup/start.sh 51 | EOF 52 | chmod +x /usr/local/bin/mailinabox 53 | 54 | # Ask the user for the PRIMARY_HOSTNAME, PUBLIC_IP, and PUBLIC_IPV6, 55 | # if values have not already been set in environment variables. When running 56 | # non-interactively, be sure to set values for all! Also sets STORAGE_USER and 57 | # STORAGE_ROOT. 58 | source setup/questions.sh 59 | 60 | # Run some network checks to make sure setup on this machine makes sense. 61 | # Skip on existing installs since we don't want this to block the ability to 62 | # upgrade, and these checks are also in the control panel status checks. 63 | if [ -z "${DEFAULT_PRIMARY_HOSTNAME:-}" ]; then 64 | if [ -z "${SKIP_NETWORK_CHECKS:-}" ]; then 65 | source setup/network-checks.sh 66 | fi 67 | fi 68 | 69 | # Create the STORAGE_USER and STORAGE_ROOT directory if they don't already exist. 70 | # 71 | # Set the directory and all of its parent directories' permissions to world 72 | # readable since it holds files owned by different processes. 73 | # 74 | # If the STORAGE_ROOT is missing the mailinabox.version file that lists a 75 | # migration (schema) number for the files stored there, assume this is a fresh 76 | # installation to that directory and write the file to contain the current 77 | # migration number for this version of Mail-in-a-Box. 78 | if ! id -u "$STORAGE_USER" >/dev/null 2>&1; then 79 | useradd -m "$STORAGE_USER" 80 | fi 81 | if [ ! -d "$STORAGE_ROOT" ]; then 82 | mkdir -p "$STORAGE_ROOT" 83 | fi 84 | f=$STORAGE_ROOT 85 | while [[ $f != / ]]; do chmod a+rx "$f"; f=$(dirname "$f"); done; 86 | if [ ! -f "$STORAGE_ROOT/mailinabox.version" ]; then 87 | setup/migrate.py --current > "$STORAGE_ROOT/mailinabox.version" 88 | chown "$STORAGE_USER:$STORAGE_USER" "$STORAGE_ROOT/mailinabox.version" 89 | fi 90 | 91 | # Save the global options in /etc/mailinabox.conf so that standalone 92 | # tools know where to look for data. The default MTA_STS_MODE setting 93 | # is blank unless set by an environment variable, but see web.sh for 94 | # how that is interpreted. 95 | cat > /etc/mailinabox.conf << EOF; 96 | STORAGE_USER=$STORAGE_USER 97 | STORAGE_ROOT=$STORAGE_ROOT 98 | PRIMARY_HOSTNAME=$PRIMARY_HOSTNAME 99 | PUBLIC_IP=$PUBLIC_IP 100 | PUBLIC_IPV6=$PUBLIC_IPV6 101 | PRIVATE_IP=$PRIVATE_IP 102 | PRIVATE_IPV6=$PRIVATE_IPV6 103 | MTA_STS_MODE=${DEFAULT_MTA_STS_MODE:-enforce} 104 | EOF 105 | 106 | # Start service configuration. 107 | source setup/system.sh 108 | source setup/ssl.sh 109 | source setup/dns.sh 110 | source setup/mail-postfix.sh 111 | source setup/mail-dovecot.sh 112 | source setup/mail-users.sh 113 | source setup/dkim.sh 114 | source setup/spamassassin.sh 115 | source setup/web.sh 116 | source setup/webmail.sh 117 | source setup/nextcloud.sh 118 | source setup/zpush.sh 119 | source setup/management.sh 120 | source setup/munin.sh 121 | 122 | # Wait for the management daemon to start... 123 | until nc -z -w 4 127.0.0.1 10222 124 | do 125 | echo "Waiting for the Mail-in-a-Box management daemon to start..." 126 | sleep 2 127 | done 128 | 129 | # ...and then have it write the DNS and nginx configuration files and start those 130 | # services. 131 | tools/dns_update 132 | tools/web_update 133 | 134 | # Give fail2ban another restart. The log files may not all have been present when 135 | # fail2ban was first configured, but they should exist now. 136 | restart_service fail2ban 137 | 138 | # If there aren't any mail users yet, create one. 139 | source setup/firstuser.sh 140 | 141 | # Register with Let's Encrypt, including agreeing to the Terms of Service. 142 | # We'd let certbot ask the user interactively, but when this script is 143 | # run in the recommended curl-pipe-to-bash method there is no TTY and 144 | # certbot will fail if it tries to ask. 145 | if [ ! -d "$STORAGE_ROOT/ssl/lets_encrypt/accounts/acme-v02.api.letsencrypt.org/" ]; then 146 | echo 147 | echo "-----------------------------------------------" 148 | echo "Mail-in-a-Box uses Let's Encrypt to provision free SSL/TLS certificates" 149 | echo "to enable HTTPS connections to your box. We're automatically" 150 | echo "agreeing you to their subscriber agreement. See https://letsencrypt.org." 151 | echo 152 | certbot register --register-unsafely-without-email --agree-tos --config-dir "$STORAGE_ROOT/ssl/lets_encrypt" 153 | fi 154 | 155 | # Done. 156 | echo 157 | echo "-----------------------------------------------" 158 | echo 159 | echo "Your Mail-in-a-Box is running." 160 | echo 161 | echo "Please log in to the control panel for further instructions at:" 162 | echo 163 | if management/status_checks.py --check-primary-hostname; then 164 | # Show the nice URL if it appears to be resolving and has a valid certificate. 165 | echo "https://$PRIMARY_HOSTNAME/admin" 166 | echo 167 | echo "If you have a DNS problem put the box's IP address in the URL" 168 | echo "(https://$PUBLIC_IP/admin) but then check the TLS fingerprint:" 169 | openssl x509 -in "$STORAGE_ROOT/ssl/ssl_certificate.pem" -noout -fingerprint -sha256\ 170 | | sed "s/SHA256 Fingerprint=//i" 171 | else 172 | echo "https://$PUBLIC_IP/admin" 173 | echo 174 | echo "You will be alerted that the website has an invalid certificate. Check that" 175 | echo "the certificate fingerprint matches:" 176 | echo 177 | openssl x509 -in "$STORAGE_ROOT/ssl/ssl_certificate.pem" -noout -fingerprint -sha256\ 178 | | sed "s/SHA256 Fingerprint=//i" 179 | echo 180 | echo "Then you can confirm the security exception and continue." 181 | echo 182 | fi 183 | -------------------------------------------------------------------------------- /setup/web.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # HTTP: Turn on a web server serving static files 3 | ################################################# 4 | 5 | source setup/functions.sh # load our functions 6 | source /etc/mailinabox.conf # load global vars 7 | 8 | # Some Ubuntu images start off with Apache. Remove it since we 9 | # will use nginx. Use autoremove to remove any Apache dependencies. 10 | if [ -f /usr/sbin/apache2 ]; then 11 | echo "Removing apache..." 12 | hide_output apt-get -y purge apache2 apache2-* 13 | hide_output apt-get -y --purge autoremove 14 | fi 15 | 16 | # Install nginx and a PHP FastCGI daemon. 17 | # 18 | # Turn off nginx's default website. 19 | 20 | echo "Installing Nginx (web server)..." 21 | 22 | apt_install nginx php"${PHP_VER}"-cli php"${PHP_VER}"-fpm idn2 23 | 24 | rm -f /etc/nginx/sites-enabled/default 25 | 26 | # Copy in a nginx configuration file for common and best-practices 27 | # SSL settings from @konklone. Replace STORAGE_ROOT so it can find 28 | # the DH params. 29 | rm -f /etc/nginx/nginx-ssl.conf # we used to put it here 30 | sed "s#STORAGE_ROOT#$STORAGE_ROOT#" \ 31 | conf/nginx-ssl.conf > /etc/nginx/conf.d/ssl.conf 32 | 33 | # Fix some nginx defaults. 34 | # 35 | # The server_names_hash_bucket_size seems to prevent long domain names! 36 | # The default, according to nginx's docs, depends on "the size of the 37 | # processor’s cache line." It could be as low as 32. We fixed it at 38 | # 64 in 2014 to accommodate a long domain name (20 characters?). But 39 | # even at 64, a 58-character domain name won't work (#93), so now 40 | # we're going up to 128. 41 | # 42 | # Drop TLSv1.0, TLSv1.1, following the Mozilla "Intermediate" recommendations 43 | # at https://ssl-config.mozilla.org/#server=nginx&server-version=1.17.0&config=intermediate&openssl-version=1.1.1. 44 | tools/editconf.py /etc/nginx/nginx.conf -s \ 45 | server_names_hash_bucket_size="128;" \ 46 | ssl_protocols="TLSv1.2 TLSv1.3;" 47 | 48 | # Tell PHP not to expose its version number in the X-Powered-By header. 49 | tools/editconf.py /etc/php/"$PHP_VER"/fpm/php.ini -c ';' \ 50 | expose_php=Off 51 | 52 | # Set PHPs default charset to UTF-8, since we use it. See #367. 53 | tools/editconf.py /etc/php/"$PHP_VER"/fpm/php.ini -c ';' \ 54 | default_charset="UTF-8" 55 | 56 | # Configure the path environment for php-fpm 57 | tools/editconf.py /etc/php/"$PHP_VER"/fpm/pool.d/www.conf -c ';' \ 58 | env[PATH]=/usr/local/bin:/usr/bin:/bin \ 59 | 60 | # Configure php-fpm based on the amount of memory the machine has 61 | # This is based on the nextcloud manual for performance tuning: https://docs.nextcloud.com/server/17/admin_manual/installation/server_tuning.html 62 | # Some synchronisation issues can occur when many people access the site at once. 63 | # The pm=ondemand setting is used for memory constrained machines < 2GB, this is copied over from PR: 1216 64 | TOTAL_PHYSICAL_MEM=$(head -n 1 /proc/meminfo | awk '{print $2}' || /bin/true) 65 | if [ "$TOTAL_PHYSICAL_MEM" -lt 1000000 ] 66 | then 67 | tools/editconf.py /etc/php/"$PHP_VER"/fpm/pool.d/www.conf -c ';' \ 68 | pm=ondemand \ 69 | pm.max_children=8 \ 70 | pm.start_servers=2 \ 71 | pm.min_spare_servers=1 \ 72 | pm.max_spare_servers=3 73 | elif [ "$TOTAL_PHYSICAL_MEM" -lt 2000000 ] 74 | then 75 | tools/editconf.py /etc/php/"$PHP_VER"/fpm/pool.d/www.conf -c ';' \ 76 | pm=ondemand \ 77 | pm.max_children=16 \ 78 | pm.start_servers=4 \ 79 | pm.min_spare_servers=1 \ 80 | pm.max_spare_servers=6 81 | elif [ "$TOTAL_PHYSICAL_MEM" -lt 3000000 ] 82 | then 83 | tools/editconf.py /etc/php/"$PHP_VER"/fpm/pool.d/www.conf -c ';' \ 84 | pm=dynamic \ 85 | pm.max_children=60 \ 86 | pm.start_servers=6 \ 87 | pm.min_spare_servers=3 \ 88 | pm.max_spare_servers=9 89 | else 90 | tools/editconf.py /etc/php/"$PHP_VER"/fpm/pool.d/www.conf -c ';' \ 91 | pm=dynamic \ 92 | pm.max_children=120 \ 93 | pm.start_servers=12 \ 94 | pm.min_spare_servers=6 \ 95 | pm.max_spare_servers=18 96 | fi 97 | 98 | # Other nginx settings will be configured by the management service 99 | # since it depends on what domains we're serving, which we don't know 100 | # until mail accounts have been created. 101 | 102 | # Create the iOS/OS X Mobile Configuration file which is exposed via the 103 | # nginx configuration at /mailinabox-mobileconfig. 104 | mkdir -p /var/lib/mailinabox 105 | chmod a+rx /var/lib/mailinabox 106 | cat conf/ios-profile.xml \ 107 | | sed "s/PRIMARY_HOSTNAME/$PRIMARY_HOSTNAME/" \ 108 | | sed "s/UUID1/$(cat /proc/sys/kernel/random/uuid)/" \ 109 | | sed "s/UUID2/$(cat /proc/sys/kernel/random/uuid)/" \ 110 | | sed "s/UUID3/$(cat /proc/sys/kernel/random/uuid)/" \ 111 | | sed "s/UUID4/$(cat /proc/sys/kernel/random/uuid)/" \ 112 | > /var/lib/mailinabox/mobileconfig.xml 113 | chmod a+r /var/lib/mailinabox/mobileconfig.xml 114 | 115 | # Create the Mozilla Auto-configuration file which is exposed via the 116 | # nginx configuration at /.well-known/autoconfig/mail/config-v1.1.xml. 117 | # The format of the file is documented at: 118 | # https://wiki.mozilla.org/Thunderbird:Autoconfiguration:ConfigFileFormat 119 | # and https://developer.mozilla.org/en-US/docs/Mozilla/Thunderbird/Autoconfiguration/FileFormat/HowTo. 120 | cat conf/mozilla-autoconfig.xml \ 121 | | sed "s/PRIMARY_HOSTNAME/$PRIMARY_HOSTNAME/" \ 122 | > /var/lib/mailinabox/mozilla-autoconfig.xml 123 | chmod a+r /var/lib/mailinabox/mozilla-autoconfig.xml 124 | 125 | # Create a generic mta-sts.txt file which is exposed via the 126 | # nginx configuration at /.well-known/mta-sts.txt 127 | # more documentation is available on: 128 | # https://www.uriports.com/blog/mta-sts-explained/ 129 | # default mode is "enforce". In /etc/mailinabox.conf change 130 | # "MTA_STS_MODE=testing" which means "Messages will be delivered 131 | # as though there was no failure but a report will be sent if 132 | # TLS-RPT is configured" if you are not sure you want this yet. Or "none". 133 | PUNY_PRIMARY_HOSTNAME=$(echo "$PRIMARY_HOSTNAME" | idn2) 134 | cat conf/mta-sts.txt \ 135 | | sed "s/MODE/${MTA_STS_MODE}/" \ 136 | | sed "s/PRIMARY_HOSTNAME/$PUNY_PRIMARY_HOSTNAME/" \ 137 | > /var/lib/mailinabox/mta-sts.txt 138 | chmod a+r /var/lib/mailinabox/mta-sts.txt 139 | 140 | # make a default homepage 141 | if [ -d "$STORAGE_ROOT/www/static" ]; then mv "$STORAGE_ROOT/www/static" "$STORAGE_ROOT/www/default"; fi # migration #NODOC 142 | mkdir -p "$STORAGE_ROOT/www/default" 143 | if [ ! -f "$STORAGE_ROOT/www/default/index.html" ]; then 144 | cp conf/www_default.html "$STORAGE_ROOT/www/default/index.html" 145 | fi 146 | chown -R "$STORAGE_USER" "$STORAGE_ROOT/www" 147 | 148 | # Start services. 149 | restart_service nginx 150 | restart_service php"$PHP_VER"-fpm 151 | 152 | # Open ports. 153 | ufw_allow http 154 | ufw_allow https 155 | -------------------------------------------------------------------------------- /setup/zpush.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Z-Push: The Microsoft Exchange protocol server 4 | # ---------------------------------------------- 5 | # 6 | # Mostly for use on iOS which doesn't support IMAP IDLE. 7 | # 8 | # Although Ubuntu ships Z-Push (as d-push) it has a dependency on Apache 9 | # so we won't install it that way. 10 | # 11 | # Thanks to http://frontender.ch/publikationen/push-mail-server-using-nginx-and-z-push.html. 12 | 13 | source setup/functions.sh # load our functions 14 | source /etc/mailinabox.conf # load global vars 15 | 16 | # Prereqs. 17 | 18 | echo "Installing Z-Push (Exchange/ActiveSync server)..." 19 | apt_install \ 20 | php"${PHP_VER}"-soap php"${PHP_VER}"-imap libawl-php php"$PHP_VER"-xml php"${PHP_VER}"-intl 21 | 22 | phpenmod -v "$PHP_VER" imap 23 | 24 | # Copy Z-Push into place. 25 | VERSION=2.7.5 26 | TARGETHASH=f0b0b06e255f3496173ab9d28a4f2d985184720e 27 | needs_update=0 #NODOC 28 | if [ ! -f /usr/local/lib/z-push/version ]; then 29 | needs_update=1 #NODOC 30 | elif [[ $VERSION != $(cat /usr/local/lib/z-push/version) ]]; then 31 | # checks if the version 32 | needs_update=1 #NODOC 33 | fi 34 | if [ $needs_update == 1 ]; then 35 | # Download 36 | wget_verify "https://github.com/Z-Hub/Z-Push/archive/refs/tags/$VERSION.zip" $TARGETHASH /tmp/z-push.zip 37 | 38 | # Extract into place. 39 | rm -rf /usr/local/lib/z-push /tmp/z-push 40 | unzip -q /tmp/z-push.zip -d /tmp/z-push 41 | mv /tmp/z-push/*/src /usr/local/lib/z-push 42 | rm -rf /tmp/z-push.zip /tmp/z-push 43 | 44 | # Create admin and top scripts with PHP_VER 45 | rm -f /usr/sbin/z-push-{admin,top} 46 | echo '#!/bin/bash' > /usr/sbin/z-push-admin 47 | echo php"$PHP_VER" /usr/local/lib/z-push/z-push-admin.php '"$@"' >> /usr/sbin/z-push-admin 48 | chmod 755 /usr/sbin/z-push-admin 49 | echo '#!/bin/bash' > /usr/sbin/z-push-top 50 | echo php"$PHP_VER" /usr/local/lib/z-push/z-push-top.php '"$@"' >> /usr/sbin/z-push-top 51 | chmod 755 /usr/sbin/z-push-top 52 | 53 | echo $VERSION > /usr/local/lib/z-push/version 54 | fi 55 | 56 | # Configure default config. 57 | sed -i "s^define('TIMEZONE', .*^define('TIMEZONE', '$(cat /etc/timezone)');^" /usr/local/lib/z-push/config.php 58 | sed -i "s/define('BACKEND_PROVIDER', .*/define('BACKEND_PROVIDER', 'BackendCombined');/" /usr/local/lib/z-push/config.php 59 | sed -i "s/define('USE_FULLEMAIL_FOR_LOGIN', .*/define('USE_FULLEMAIL_FOR_LOGIN', true);/" /usr/local/lib/z-push/config.php 60 | sed -i "s/define('LOGLEVEL', .*/define('LOGLEVEL', LOGLEVEL_ERROR);/" /usr/local/lib/z-push/config.php 61 | 62 | # Configure BACKEND 63 | rm -f /usr/local/lib/z-push/backend/combined/config.php 64 | cp conf/zpush/backend_combined.php /usr/local/lib/z-push/backend/combined/config.php 65 | 66 | # Configure IMAP 67 | rm -f /usr/local/lib/z-push/backend/imap/config.php 68 | cp conf/zpush/backend_imap.php /usr/local/lib/z-push/backend/imap/config.php 69 | sed -i "s%STORAGE_ROOT%$STORAGE_ROOT%" /usr/local/lib/z-push/backend/imap/config.php 70 | 71 | # Configure CardDav 72 | rm -f /usr/local/lib/z-push/backend/carddav/config.php 73 | cp conf/zpush/backend_carddav.php /usr/local/lib/z-push/backend/carddav/config.php 74 | 75 | # Configure CalDav 76 | rm -f /usr/local/lib/z-push/backend/caldav/config.php 77 | cp conf/zpush/backend_caldav.php /usr/local/lib/z-push/backend/caldav/config.php 78 | 79 | # Configure Autodiscover 80 | rm -f /usr/local/lib/z-push/autodiscover/config.php 81 | cp conf/zpush/autodiscover_config.php /usr/local/lib/z-push/autodiscover/config.php 82 | sed -i "s/PRIMARY_HOSTNAME/$PRIMARY_HOSTNAME/" /usr/local/lib/z-push/autodiscover/config.php 83 | sed -i "s^define('TIMEZONE', .*^define('TIMEZONE', '$(cat /etc/timezone)');^" /usr/local/lib/z-push/autodiscover/config.php 84 | 85 | # Some directories it will use. 86 | 87 | mkdir -p /var/log/z-push 88 | mkdir -p /var/lib/z-push 89 | chmod 750 /var/log/z-push 90 | chmod 750 /var/lib/z-push 91 | chown www-data:www-data /var/log/z-push 92 | chown www-data:www-data /var/lib/z-push 93 | 94 | # Add log rotation 95 | 96 | cat > /etc/logrotate.d/z-push < within_seconds: 201 | raise Exception("Test failed to make %s requests in %d seconds." % (count, within_seconds)) 202 | 203 | # Wait a moment for the block to be put into place. 204 | time.sleep(4) 205 | 206 | # The next call should fail. 207 | print("*", end=" ", flush=True) 208 | try: 209 | testfunc(*args) 210 | except IsBlocked: 211 | # Success -- this one is supposed to be refused. 212 | print("blocked [OK]") 213 | return True # OK 214 | 215 | print("not blocked!") 216 | return False 217 | 218 | ###################################################################### 219 | 220 | if __name__ == "__main__": 221 | # run tests 222 | 223 | # SMTP bans at 10 even though we say 20 in the config because we get 224 | # doubled-up warnings in the logs, we'll let that be for now 225 | run_test(smtp_test, [], 10, 30, 8) 226 | 227 | # IMAP 228 | run_test(imap_test, [], 20, 30, 4) 229 | 230 | # POP 231 | run_test(pop_test, [], 20, 30, 4) 232 | 233 | # Managesieve 234 | run_test(managesieve_test, [], 20, 30, 4) 235 | 236 | # Mail-in-a-Box control panel 237 | run_test(http_test, ["/admin/login", 200], 20, 30, 1) 238 | 239 | # Munin via the Mail-in-a-Box control panel 240 | run_test(http_test, ["/admin/munin/", 401], 20, 30, 1) 241 | 242 | # ownCloud 243 | run_test(http_test, ["/cloud/remote.php/webdav", 401, None, None, [owncloud_user, "aa"]], 20, 120, 1) 244 | 245 | # restart fail2ban so that this client machine is no longer blocked 246 | restart_fail2ban_service(final=True) 247 | -------------------------------------------------------------------------------- /tests/pip-requirements.txt: -------------------------------------------------------------------------------- 1 | dnspython3 2 | -------------------------------------------------------------------------------- /tests/test_dns.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Tests the DNS configuration of a Mail-in-a-Box. 4 | # 5 | # tests/dns.py ipaddr hostname 6 | # 7 | # where ipaddr is the IP address of your Mail-in-a-Box 8 | # and hostname is the domain name to check the DNS for. 9 | 10 | import sys, re 11 | import dns.reversename, dns.resolver 12 | 13 | if len(sys.argv) < 3: 14 | print("Usage: tests/dns.py ipaddress hostname [primary hostname]") 15 | sys.exit(1) 16 | 17 | ipaddr, hostname = sys.argv[1:3] 18 | primary_hostname = hostname 19 | if len(sys.argv) == 4: 20 | primary_hostname = sys.argv[3] 21 | 22 | def test(server, description): 23 | tests = [ 24 | (hostname, "A", ipaddr), 25 | #(hostname, "NS", "ns1.%s.;ns2.%s." % (primary_hostname, primary_hostname)), 26 | ("ns1." + primary_hostname, "A", ipaddr), 27 | ("ns2." + primary_hostname, "A", ipaddr), 28 | ("www." + hostname, "A", ipaddr), 29 | (hostname, "MX", "10 " + primary_hostname + "."), 30 | (hostname, "TXT", '"v=spf1 mx -all"'), 31 | ("mail._domainkey." + hostname, "TXT", '"v=DKIM1; k=rsa; s=email; " "p=__KEY__"'), 32 | #("_adsp._domainkey." + hostname, "TXT", "\"dkim=all\""), 33 | ("_dmarc." + hostname, "TXT", '"v=DMARC1; p=quarantine;"'), 34 | ] 35 | return test2(tests, server, description) 36 | 37 | def test_ptr(server, description): 38 | ipaddr_rev = dns.reversename.from_address(ipaddr) 39 | tests = [ 40 | (ipaddr_rev, "PTR", hostname+'.'), 41 | ] 42 | return test2(tests, server, description) 43 | 44 | def test2(tests, server, description): 45 | first = True 46 | resolver = dns.resolver.get_default_resolver() 47 | resolver.nameservers = [server] 48 | for qname, rtype, expected_answer in tests: 49 | # do the query and format the result as a string 50 | try: 51 | response = dns.resolver.resolve(qname, rtype) 52 | except dns.resolver.NoNameservers: 53 | # host did not have an answer for this query 54 | print("Could not connect to %s for DNS query." % server) 55 | sys.exit(1) 56 | except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): 57 | # host did not have an answer for this query; not sure what the 58 | # difference is between the two exceptions 59 | response = ["[no value]"] 60 | response = ";".join(str(r) for r in response) 61 | response = re.sub(r"(\"p=).*(\")", r"\1__KEY__\2", response) # normalize DKIM key 62 | response = response.replace('"" ', "") # normalize TXT records (DNSSEC signing inserts empty text string components) 63 | 64 | # is it right? 65 | if response == expected_answer: 66 | #print(server, ":", qname, rtype, "?", response) 67 | continue 68 | 69 | # show problem 70 | if first: 71 | print("Incorrect DNS Response from", description) 72 | print() 73 | print("QUERY ", "RESPONSE ", "CORRECT VALUE", sep='\t') 74 | first = False 75 | 76 | print((qname + "/" + rtype).ljust(20), response.ljust(12), expected_answer, sep='\t') 77 | return first # success 78 | 79 | # Test the response from the machine itself. 80 | if not test(ipaddr, "Mail-in-a-Box"): 81 | print () 82 | print ("Please run the Mail-in-a-Box setup script on %s again." % hostname) 83 | sys.exit(1) 84 | else: 85 | print ("The Mail-in-a-Box provided correct DNS answers.") 86 | print () 87 | 88 | # If those settings are OK, also test Google's Public DNS 89 | # to see if the machine is hooked up to recursive DNS properly. 90 | if not test("8.8.8.8", "Google Public DNS"): 91 | print () 92 | print ("Check that the nameserver settings for %s are correct at your domain registrar. It may take a few hours for Google Public DNS to update after changes on your Mail-in-a-Box." % hostname) 93 | sys.exit(1) 94 | else: 95 | print ("Your domain registrar or DNS host appears to be configured correctly as well. Public DNS provides the same answers.") 96 | print () 97 | 98 | # And if that's OK, also check reverse DNS (the PTR record). 99 | if not test_ptr("8.8.8.8", "Google Public DNS (Reverse DNS)"): 100 | print () 101 | print (f"The reverse DNS for {hostname} is not correct. Consult your ISP for how to set the reverse DNS (also called the PTR record) for {hostname} to {ipaddr}.") 102 | sys.exit(1) 103 | else: 104 | print ("And the reverse DNS for the domain is correct.") 105 | print () 106 | print ("DNS is OK.") 107 | -------------------------------------------------------------------------------- /tests/test_mail.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Tests sending and receiving mail by sending a test message to yourself. 3 | 4 | import sys, imaplib, smtplib, uuid, time 5 | import socket, dns.reversename, dns.resolver 6 | 7 | if len(sys.argv) < 3: 8 | print("Usage: tests/mail.py hostname emailaddress password") 9 | sys.exit(1) 10 | 11 | host, emailaddress, pw = sys.argv[1:4] 12 | 13 | # Attempt to login with IMAP. Our setup uses email addresses 14 | # as IMAP/SMTP usernames. 15 | try: 16 | M = imaplib.IMAP4_SSL(host) 17 | M.login(emailaddress, pw) 18 | except OSError as e: 19 | print("Connection error:", e) 20 | sys.exit(1) 21 | except imaplib.IMAP4.error as e: 22 | # any sort of login error 23 | e = ", ".join(a.decode("utf8") for a in e.args) 24 | print("IMAP error:", e) 25 | sys.exit(1) 26 | 27 | M.select() 28 | print("IMAP login is OK.") 29 | 30 | # Attempt to send a mail to ourself. 31 | mailsubject = "Mail-in-a-Box Automated Test Message " + uuid.uuid4().hex 32 | emailto = emailaddress 33 | msg = f"""From: {emailaddress} 34 | To: {emailto} 35 | Subject: {mailsubject} 36 | 37 | This is a test message. It should be automatically deleted by the test script.""" 38 | 39 | # Connect to the server on the SMTP submission TLS port. 40 | server = smtplib.SMTP_SSL(host) 41 | #server.set_debuglevel(1) 42 | 43 | # Verify that the EHLO name matches the server's reverse DNS. 44 | ipaddr = socket.gethostbyname(host) # IPv4 only! 45 | reverse_ip = dns.reversename.from_address(ipaddr) # e.g. "1.0.0.127.in-addr.arpa." 46 | try: 47 | reverse_dns = dns.resolver.resolve(reverse_ip, 'PTR')[0].target.to_text(omit_final_dot=True) # => hostname 48 | except dns.resolver.NXDOMAIN: 49 | print("Reverse DNS lookup failed for %s. SMTP EHLO name check skipped." % ipaddr) 50 | reverse_dns = None 51 | if reverse_dns is not None: 52 | server.ehlo_or_helo_if_needed() # must send EHLO before getting the server's EHLO name 53 | helo_name = server.ehlo_resp.decode("utf8").split("\n")[0] # first line is the EHLO name 54 | if helo_name != reverse_dns: 55 | print("The server's EHLO name does not match its reverse hostname. Check DNS settings.") 56 | else: 57 | print("SMTP EHLO name (%s) is OK." % helo_name) 58 | 59 | # Login and send a test email. 60 | server.login(emailaddress, pw) 61 | server.sendmail(emailaddress, [emailto], msg) 62 | server.quit() 63 | print("SMTP submission is OK.") 64 | 65 | while True: 66 | # Wait so the message can propagate to the inbox. 67 | time.sleep(10) 68 | 69 | # Read the subject lines of all of the emails in the inbox 70 | # to find our test message, and then delete it. 71 | found = False 72 | typ, data = M.search(None, 'ALL') 73 | for num in data[0].split(): 74 | typ, data = M.fetch(num, '(BODY[HEADER.FIELDS (SUBJECT)])') 75 | imapsubjectline = data[0][1].strip().decode("utf8") 76 | if imapsubjectline == "Subject: " + mailsubject: 77 | # We found our test message. 78 | found = True 79 | 80 | # To test DKIM, download the whole mssage body. Unfortunately, 81 | # pydkim doesn't actually work. 82 | # You must 'sudo apt-get install python3-dkim python3-dnspython' first. 83 | #typ, msgdata = M.fetch(num, '(RFC822)') 84 | #msg = msgdata[0][1] 85 | #if dkim.verify(msg): 86 | # print("DKIM signature on the test message is OK (verified).") 87 | #else: 88 | # print("DKIM signature on the test message failed verification.") 89 | 90 | # Delete the test message. 91 | M.store(num, '+FLAGS', '\\Deleted') 92 | M.expunge() 93 | 94 | break 95 | 96 | if found: 97 | break 98 | 99 | print("Test message not present in the inbox yet...") 100 | 101 | M.close() 102 | M.logout() 103 | 104 | print("Test message sent & received successfully.") 105 | -------------------------------------------------------------------------------- /tests/test_smtp_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import smtplib, sys 3 | 4 | if len(sys.argv) < 3: 5 | print("Usage: tests/smtp_server.py host email.to email.from") 6 | sys.exit(1) 7 | 8 | host, toaddr, fromaddr = sys.argv[1:4] 9 | msg = f"""From: {fromaddr} 10 | To: {toaddr} 11 | Subject: SMTP server test 12 | 13 | This is a test message.""" 14 | 15 | server = smtplib.SMTP(host, 25) 16 | server.set_debuglevel(1) 17 | server.sendmail(fromaddr, [toaddr], msg) 18 | server.quit() 19 | 20 | -------------------------------------------------------------------------------- /tests/tls.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | # Runs SSLyze on the TLS endpoints of a box and outputs 4 | # the results so we can inspect the settings and compare 5 | # against a known good version in tls_results.txt. 6 | # 7 | # Make sure you have SSLyze available: 8 | # wget https://github.com/nabla-c0d3/sslyze/releases/download/release-0.11/sslyze-0_11-linux64.zip 9 | # unzip sslyze-0_11-linux64.zip 10 | # 11 | # Then run: 12 | # 13 | # python3 tls.py yourservername 14 | # 15 | # If you are on a residential network that blocks outbound 16 | # port 25 connections, then you can proxy the connections 17 | # through some other host you can ssh into (maybe the box 18 | # itself?): 19 | # 20 | # python3 tls.py --proxy user@ssh_host yourservername 21 | # 22 | # (This will launch "ssh -N -L10023:yourservername:testport user@ssh_host" 23 | # to create a tunnel.) 24 | 25 | import sys, subprocess, re, time, json, csv, io, urllib.request 26 | 27 | ###################################################################### 28 | 29 | # PARSE COMMAND LINE 30 | 31 | proxy = None 32 | args = list(sys.argv[1:]) 33 | while len(args) > 0: 34 | if args[0] == "--proxy": 35 | args.pop(0) 36 | proxy = args.pop(0) 37 | break 38 | 39 | if len(args) == 0: 40 | print("Usage: python3 tls.py [--proxy ssh_host] hostname") 41 | sys.exit(0) 42 | 43 | host = args[0] 44 | 45 | ###################################################################### 46 | 47 | SSLYZE = "sslyze-0_11-linux64/sslyze/sslyze.py" 48 | 49 | common_opts = ["--sslv2", "--sslv3", "--tlsv1", "--tlsv1_1", "--tlsv1_2", "--reneg", "--resum", 50 | "--hide_rejected_ciphers", "--compression", "--heartbleed"] 51 | 52 | # Recommendations from Mozilla as of May 20, 2015 at 53 | # https://wiki.mozilla.org/Security/Server_Side_TLS. 54 | # 55 | # The 'modern' ciphers support Firefox 27, Chrome 22, IE 11, 56 | # Opera 14, Safari 7, Android 4.4, Java 8. Assumes TLSv1.1, 57 | # TLSv1.2 only, though we may also be allowing TLSv3. 58 | # 59 | # The 'intermediate' ciphers support Firefox 1, Chrome 1, IE 7, 60 | # Opera 5, Safari 1, Windows XP IE8, Android 2.3, Java 7. 61 | # Assumes TLSv1, TLSv1.1, TLSv1.2. 62 | # 63 | # The 'old' ciphers bring compatibility back to Win XP IE 6. 64 | MOZILLA_CIPHERS_MODERN = "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256" 65 | MOZILLA_CIPHERS_INTERMEDIATE = "ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS" 66 | MOZILLA_CIPHERS_OLD = "ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:ECDHE-RSA-DES-CBC3-SHA:ECDHE-ECDSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:DES-CBC3-SHA:HIGH:SEED:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!RSAPSK:!aDH:!aECDH:!EDH-DSS-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA:!SRP" 67 | 68 | ###################################################################### 69 | 70 | def sslyze(opts, port, ok_ciphers): 71 | # Print header. 72 | header = ("PORT %d" % port) 73 | print(header) 74 | print("-" * (len(header))) 75 | 76 | # What ciphers should we expect? 77 | ok_ciphers = subprocess.check_output(["openssl", "ciphers", ok_ciphers]).decode("utf8").strip().split(":") 78 | 79 | # Form the SSLyze connection string. 80 | connection_string = host + ":" + str(port) 81 | 82 | # Proxy via SSH. 83 | proxy_proc = None 84 | if proxy: 85 | connection_string = "localhost:10023" 86 | proxy_proc = subprocess.Popen(["ssh", "-N", "-L10023:%s:%d" % (host, port), proxy]) 87 | time.sleep(3) 88 | 89 | try: 90 | # Execute SSLyze. 91 | out = subprocess.check_output([SSLYZE, *common_opts, *opts, connection_string]) 92 | out = out.decode("utf8") 93 | 94 | # Trim output to make better for storing in git. 95 | if "SCAN RESULTS FOR" not in out: 96 | # Failed. Just output the error. 97 | out = re.sub("[\\w\\W]*CHECKING HOST\\(S\\) AVAILABILITY\n\\s*-+\n", "", out) # chop off header that shows the host we queried 98 | out = re.sub("[\\w\\W]*SCAN RESULTS FOR.*\n\\s*-+\n", "", out) # chop off header that shows the host we queried 99 | out = re.sub("SCAN COMPLETED IN .*", "", out) 100 | out = out.rstrip(" \n-") + "\n" 101 | 102 | # Print. 103 | print(out) 104 | 105 | # Pull out the accepted ciphers list for each SSL/TLS protocol 106 | # version outputted. 107 | accepted_ciphers = set() 108 | for ciphers in re.findall(" Accepted:([\\w\\W]*?)\n *\n", out): 109 | accepted_ciphers |= set(re.findall("\n\\s*(\\S*)", ciphers)) 110 | 111 | # Compare to what Mozilla recommends, for a given modernness-level. 112 | print(" Should Not Offer: " + (", ".join(sorted(accepted_ciphers-set(ok_ciphers))) or "(none -- good)")) 113 | print(" Could Also Offer: " + (", ".join(sorted(set(ok_ciphers)-accepted_ciphers)) or "(none -- good)")) 114 | 115 | # What clients does that mean we support on this protocol? 116 | supported_clients = { } 117 | for cipher in accepted_ciphers: 118 | if cipher in cipher_clients: 119 | for client in cipher_clients[cipher]: 120 | supported_clients[client] = supported_clients.get(client, 0) + 1 121 | print(" Supported Clients: " + (", ".join(sorted(supported_clients.keys(), key = lambda client : -supported_clients[client])))) 122 | 123 | # Blank line. 124 | print() 125 | 126 | finally: 127 | if proxy_proc: 128 | proxy_proc.terminate() 129 | try: 130 | proxy_proc.wait(5) 131 | except subprocess.TimeoutExpired: 132 | proxy_proc.kill() 133 | 134 | # Get a list of OpenSSL cipher names. 135 | cipher_names = { } 136 | for cipher in csv.DictReader(io.StringIO(urllib.request.urlopen("https://raw.githubusercontent.com/mail-in-a-box/user-agent-tls-capabilities/master/cipher_names.csv").read().decode("utf8"))): 137 | # not sure why there are some multi-line values, use first line: 138 | cipher["OpenSSL"] = cipher["OpenSSL"].split("\n")[0] 139 | cipher_names[cipher["IANA"]] = cipher["OpenSSL"] 140 | 141 | # Get a list of what clients support what ciphers, using OpenSSL cipher names. 142 | client_compatibility = json.loads(urllib.request.urlopen("https://raw.githubusercontent.com/mail-in-a-box/user-agent-tls-capabilities/master/clients.json").read().decode("utf8")) 143 | cipher_clients = { } 144 | for client in client_compatibility: 145 | if len(set(client['protocols']) & {"TLS 1.0", "TLS 1.1", "TLS 1.2"}) == 0: continue # does not support TLS 146 | for cipher in client['ciphers']: 147 | cipher_clients.setdefault(cipher_names.get(cipher), set()).add("/".join(x for x in [client['client']['name'], client['client']['version'], client['client']['platform']] if x)) 148 | 149 | # Run SSLyze on various ports. 150 | 151 | # SMTP 152 | sslyze(["--starttls=smtp"], 25, MOZILLA_CIPHERS_OLD) 153 | 154 | # SMTP Submission 155 | sslyze(["--starttls=smtp"], 587, MOZILLA_CIPHERS_MODERN) 156 | 157 | # HTTPS 158 | sslyze(["--http_get", "--chrome_sha1", "--hsts"], 443, MOZILLA_CIPHERS_INTERMEDIATE) 159 | 160 | # IMAP 161 | sslyze([], 993, MOZILLA_CIPHERS_MODERN) 162 | 163 | # POP3 164 | sslyze([], 995, MOZILLA_CIPHERS_MODERN) 165 | -------------------------------------------------------------------------------- /tools/archive_conf_files.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Use this script to make an archive of the contents of all 3 | # of the configuration files we edit with editconf.py. 4 | for fn in $(grep -hr editconf.py setup | sed "s/tools\/editconf.py //" | sed "s/ .*//" | sort | uniq); do 5 | echo ====================================================================== 6 | echo "$fn" 7 | echo ====================================================================== 8 | cat "$fn" 9 | done 10 | 11 | -------------------------------------------------------------------------------- /tools/dns_update: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | POSTDATA=dummy 3 | if [ "$1" == "--force" ]; then 4 | POSTDATA=force=1 5 | fi 6 | curl -s -d $POSTDATA --user "$(] [-t] NAME=VAL [NAME=VAL ...]") 34 | sys.exit(1) 35 | 36 | # parse command line arguments 37 | filename = sys.argv[1] 38 | settings = sys.argv[2:] 39 | 40 | delimiter = "=" 41 | delimiter_re = r"\s*=\s*" 42 | erase_setting = False 43 | comment_char = "#" 44 | folded_lines = False 45 | testing = False 46 | while settings[0][0] == "-" and settings[0] != "--": 47 | opt = settings.pop(0) 48 | if opt == "-s": 49 | # Space is the delimiter 50 | delimiter = " " 51 | delimiter_re = r"\s+" 52 | elif opt == "-e": 53 | # Erase settings that have empty values. 54 | erase_setting = True 55 | elif opt == "-w": 56 | # Line folding is possible in this file. 57 | folded_lines = True 58 | elif opt == "-c": 59 | # Specifies a different comment character. 60 | comment_char = settings.pop(0) 61 | elif opt == "-t": 62 | testing = True 63 | else: 64 | print("Invalid option.") 65 | sys.exit(1) 66 | 67 | # sanity check command line 68 | for setting in settings: 69 | try: 70 | name, value = setting.split("=", 1) 71 | except: 72 | import subprocess 73 | print("Invalid command line: ", subprocess.list2cmdline(sys.argv)) 74 | 75 | # create the new config file in memory 76 | 77 | found = set() 78 | buf = "" 79 | with open(filename, encoding="utf-8") as f: 80 | input_lines = list(f) 81 | 82 | while len(input_lines) > 0: 83 | line = input_lines.pop(0) 84 | 85 | # If this configuration file uses folded lines, append any folded lines 86 | # into our input buffer. 87 | if folded_lines and line[0] not in {comment_char, " ", ""}: 88 | while len(input_lines) > 0 and input_lines[0][0] in " \t": 89 | line += input_lines.pop(0) 90 | 91 | # See if this line is for any settings passed on the command line. 92 | for i in range(len(settings)): 93 | # Check if this line contain this setting from the command-line arguments. 94 | name, val = settings[i].split("=", 1) 95 | m = re.match( 96 | r"(\s*)" 97 | "(" + re.escape(comment_char) + r"\s*)?" 98 | + re.escape(name) + delimiter_re + r"(.*?)\s*$", 99 | line, re.S) 100 | if not m: continue 101 | indent, is_comment, existing_val = m.groups() 102 | 103 | # If this is already the setting, keep it in the file, except: 104 | # * If we've already seen it before, then remove this duplicate line. 105 | # * If val is empty and erase_setting is on, then comment it out. 106 | if is_comment is None and existing_val == val and not (not val and erase_setting): 107 | # It may be that we've already inserted this setting higher 108 | # in the file so check for that first. 109 | if i in found: break 110 | buf += line 111 | found.add(i) 112 | break 113 | 114 | # comment-out the existing line (also comment any folded lines) 115 | if is_comment is None: 116 | buf += comment_char + line.rstrip().replace("\n", "\n" + comment_char) + "\n" 117 | else: 118 | # the line is already commented, pass it through 119 | buf += line 120 | 121 | # if this option already is set don't add the setting again, 122 | # or if we're clearing the setting with -e, don't add it 123 | if (i in found) or (not val and erase_setting): 124 | break 125 | 126 | # add the new setting 127 | buf += indent + name + delimiter + val + "\n" 128 | 129 | # note that we've applied this option 130 | found.add(i) 131 | 132 | break 133 | else: 134 | # If did not match any setting names, pass this line through. 135 | buf += line 136 | 137 | # Put any settings we didn't see at the end of the file, 138 | # except settings being cleared. 139 | for i in range(len(settings)): 140 | if i not in found: 141 | name, val = settings[i].split("=", 1) 142 | if not (not val and erase_setting): 143 | buf += name + delimiter + val + "\n" 144 | 145 | if not testing: 146 | # Write out the new file. 147 | with open(filename, "w", encoding="utf-8") as f: 148 | f.write(buf) 149 | else: 150 | # Just print the new file to stdout. 151 | print(buf) 152 | -------------------------------------------------------------------------------- /tools/mail.py: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This script has moved. 3 | management/cli.py "$@" 4 | -------------------------------------------------------------------------------- /tools/owncloud-restore.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # This script will restore the backup made during an installation 4 | source /etc/mailinabox.conf # load global vars 5 | 6 | if [ -z "$1" ]; then 7 | echo "Usage: owncloud-restore.sh " 8 | echo 9 | echo "WARNING: This will restore the database to the point of the installation!" 10 | echo " This means that you will lose all changes made by users after that point" 11 | echo 12 | echo 13 | echo "Backups are stored here: $STORAGE_ROOT/owncloud-backup/" 14 | echo 15 | echo "Available backups:" 16 | echo 17 | find "$STORAGE_ROOT/owncloud-backup/"* -maxdepth 0 -type d 18 | echo 19 | echo "Supply the directory that was created during the last installation as the only commandline argument" 20 | exit 21 | fi 22 | 23 | if [ ! -f "$1/config.php" ]; then 24 | echo "This isn't a valid backup location" 25 | exit 1 26 | fi 27 | 28 | echo "Restoring backup from $1" 29 | service php8.0-fpm stop 30 | 31 | # remove the current ownCloud/Nextcloud installation 32 | rm -rf /usr/local/lib/owncloud/ 33 | # restore the current ownCloud/Nextcloud application 34 | cp -r "$1/owncloud-install" /usr/local/lib/owncloud 35 | 36 | # restore access rights 37 | chmod 750 /usr/local/lib/owncloud/{apps,config} 38 | 39 | cp "$1/owncloud.db" "$STORAGE_ROOT/owncloud/" 40 | cp "$1/config.php" "$STORAGE_ROOT/owncloud/" 41 | 42 | ln -sf "$STORAGE_ROOT/owncloud/config.php" /usr/local/lib/owncloud/config/config.php 43 | chown -f -R www-data:www-data "$STORAGE_ROOT/owncloud" /usr/local/lib/owncloud 44 | chown www-data:www-data "$STORAGE_ROOT/owncloud/config.php" 45 | 46 | sudo -u www-data "php$PHP_VER" /usr/local/lib/owncloud/occ maintenance:mode --off 47 | 48 | service php8.0-fpm start 49 | echo "Done" 50 | -------------------------------------------------------------------------------- /tools/owncloud-unlockadmin.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # This script will give you administrative access to the Nextcloud 4 | # instance running here. 5 | # 6 | # Run this at your own risk. This is for testing & experimentation 7 | # purpopses only. After this point you are on your own. 8 | 9 | source /etc/mailinabox.conf # load global vars 10 | 11 | ADMIN=$(./mail.py user admins | head -n 1) 12 | test -z "$1" || ADMIN=$1 13 | 14 | echo "I am going to unlock admin features for $ADMIN." 15 | echo "You can provide another user to unlock as the first argument of this script." 16 | echo 17 | echo "WARNING: you could break mail-in-a-box when fiddling around with Nextcloud's admin interface" 18 | echo "If in doubt, press CTRL-C to cancel." 19 | echo 20 | echo "Press enter to continue." 21 | read 22 | 23 | sudo -u www-data "php$PHP_VER" /usr/local/lib/owncloud/occ group:adduser admin "$ADMIN" && echo "Done." 24 | -------------------------------------------------------------------------------- /tools/parse-nginx-log-bootstrap-accesses.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # 3 | # This is a tool Josh uses on his box serving mailinabox.email to parse the nginx 4 | # access log to see how many people are installing Mail-in-a-Box each day, by 5 | # looking at accesses to the bootstrap.sh script (which is currently at the URL 6 | # .../setup.sh). 7 | 8 | import re, glob, gzip, os.path, json 9 | import dateutil.parser 10 | 11 | outfn = "/home/user-data/www/mailinabox.email/install-stats.json" 12 | 13 | # Make a unique list of (date, ip address) pairs so we don't double-count 14 | # accesses that are for the same install. 15 | accesses = set() 16 | 17 | # Scan the current and rotated access logs. 18 | for fn in glob.glob("/var/log/nginx/access.log*"): 19 | # Gunzip if necessary. 20 | # Loop through the lines in the access log. 21 | with (gzip.open if fn.endswith(".gz") else open)(fn, "rb") as f: 22 | for line in f: 23 | # Find lines that are GETs on the bootstrap script by either curl or wget. 24 | # (Note that we purposely skip ...?ping=1 requests which is the admin panel querying us for updates.) 25 | # (Also, the URL changed in January 2016, but we'll accept both.) 26 | m = re.match(rb"(?P\S+) - - \[(?P.*?)\] \"GET /(bootstrap.sh|setup.sh) HTTP/.*\" 200 \d+ .* \"(?:curl|wget)", line, re.I) 27 | if m: 28 | date, time = m.group("date").decode("ascii").split(":", 1) 29 | date = dateutil.parser.parse(date).date().isoformat() 30 | ip = m.group("ip").decode("ascii") 31 | accesses.add( (date, ip) ) 32 | 33 | # Aggregate by date. 34 | by_date = { } 35 | for date, ip in accesses: 36 | by_date[date] = by_date.get(date, 0) + 1 37 | 38 | # Since logs are rotated, store the statistics permanently in a JSON file. 39 | # Load in the stats from an existing file. 40 | if os.path.exists(outfn): 41 | with open(outfn, encoding="utf-8") as f: 42 | existing_data = json.load(f) 43 | for date, count in existing_data: 44 | if date not in by_date: 45 | by_date[date] = count 46 | 47 | # Turn into a list rather than a dict structure to make it ordered. 48 | by_date = sorted(by_date.items()) 49 | 50 | # Pop the last one because today's stats are incomplete. 51 | by_date.pop(-1) 52 | 53 | # Write out. 54 | with open(outfn, "w", encoding="utf-8") as f: 55 | json.dump(by_date, f, sort_keys=True, indent=True) 56 | -------------------------------------------------------------------------------- /tools/ssl_cleanup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Cleanup SSL certificates which expired more than 7 days ago from $STORAGE_ROOT/ssl and move them to $STORAGE_ROOT/ssl.expired 3 | 4 | source /etc/mailinabox.conf 5 | shopt -s extglob nullglob 6 | 7 | retain_after="$(date --date="7 days ago" +%Y%m%d)" 8 | 9 | mkdir -p $STORAGE_ROOT/ssl.expired 10 | for file in $STORAGE_ROOT/ssl/*-+([0-9])-+([0-9a-f]).pem; do 11 | pem="$(basename "$file")" 12 | not_valid_after="$(cut -d- -f1 <<< "${pem: -21}")" 13 | 14 | if [ "$not_valid_after" -lt "$retain_after" ]; then 15 | mv "$file" "$STORAGE_ROOT/ssl.expired/${pem}" 16 | fi 17 | done 18 | -------------------------------------------------------------------------------- /tools/web_update: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | curl -s -d POSTDATA --user "$(