Skip to content

Commit

Permalink
feat: add self hosting docker initial setup
Browse files Browse the repository at this point in the history
  • Loading branch information
shaunwarman committed Feb 28, 2025
1 parent 8efa384 commit f7660c3
Show file tree
Hide file tree
Showing 33 changed files with 1,740 additions and 106 deletions.
6 changes: 4 additions & 2 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
node_modules
Dockerfile*
docker-compose*.yml
node_modules
ssl
test
Dockerfile*
16 changes: 16 additions & 0 deletions .env.defaults
Original file line number Diff line number Diff line change
Expand Up @@ -430,3 +430,19 @@ GPG_SECURITY_PASSPHRASE=
MX1_HOST=mx1.forwardemail.net
MX2_HOST=mx2.forwardemail.net
SMTP_EXCHANGE_DOMAINS={{MX1_HOST}},{{MX2_HOST}}

################
## MONITORING ##
################
ENABLE_MONITOR_SERVER=true

################
## S3 Backups ##
################
REDIS_S3_BACKUPS_ENABLED=false
REDIS_S3_BACKUP_BUCKET=redis-database-backups
REDIS_S3_BACKUPS_DIR=/data

MONGO_S3_BACKUPS_ENABLED=false
MONGO_S3_BACKUP_BUCKET=mongo-database-backups
MONGO_S3_BACKUPS_DIR=/data/db
15 changes: 15 additions & 0 deletions .env.schema
Original file line number Diff line number Diff line change
Expand Up @@ -422,3 +422,18 @@ MX1_HOST=
MX2_HOST=
SMTP_EXCHANGE_DOMAINS={{MX1_HOST}},{{MX2_HOST}}

################
## MONITORING ##
################
ENABLE_MONITOR_SERVER=

################
## S3 Backups ##
################
REDIS_S3_BACKUPS_ENABLED=
REDIS_S3_BACKUP_BUCKET=
REDIS_S3_BACKUPS_DIR=

MONGO_S3_BACKUPS_ENABLED=
MONGO_S3_BACKUP_BUCKET=
MONGO_S3_BACKUPS_DIR=
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ deployment-keys
*.key
*.ca-bundle
*.crt
ssl/
.env.test
.gapp-creds.json

Expand All @@ -91,3 +92,5 @@ temp/
*.p8
.gpg-security-key
.dkim-key
redis-data
mongodb-backups
31 changes: 31 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
FROM node:20 AS builder

WORKDIR /app

ENV SELF_HOSTED=true

RUN corepack enable && corepack prepare [email protected] --activate

COPY package.json ./
RUN pnpm i

COPY . ./
ENV NODE_ENV=production
RUN pnpm run build

## build everything in the first stage and then
## leverage .dockerignore and 2nd stage to remove files
## from the built image that we don't want included
FROM node:20-slim

WORKDIR /app

ENV SELF_HOSTED=true

RUN apt update && apt install -y dnsutils netcat-traditional curl openssl && \
apt-get clean && rm -rf /var/lib/apt/lists/*

# TODO: this needs to come from or sync with env file (e.g. SQLITE_TMPDIR)
RUN mkdir -p /mnt/sqlite_storage

COPY --from=builder /app /app
3 changes: 3 additions & 0 deletions app/controllers/api/v1/enforce-paid-plan.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

const Boom = require('@hapi/boom');
const config = require('#config');

async function enforcePaidPlan(ctx, next) {
if (!ctx.isAuthenticated())
Expand All @@ -16,6 +17,8 @@ async function enforcePaidPlan(ctx, next) {
)
return next();

if (config.isSelfHosted) return next();

if (ctx.state.user.plan === 'free')
return ctx.throw(
Boom.paymentRequired(
Expand Down
7 changes: 5 additions & 2 deletions app/controllers/web/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -355,9 +355,12 @@ async function register(ctx, next) {
locale: ctx.locale
};

if (config.env === 'development') {
if (config.env === 'development' || config.isSelfHosted) {
const count = await Users.countDocuments({ group: 'admin' });
if (count === 0) query.group = 'admin';
if (count === 0) {
query.group = 'admin';
query.plan = 'team';
}
}

query[config.userFields.hasVerifiedEmail] = false;
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/web/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ async function checkGitHubStars() {
if (STARS <= 0) STARS = 1000;
}

if (config.env !== 'test') {
if (config.env !== 'test' && !config.isSelfHosted) {
checkGitHubStars();
setInterval(checkGitHubStars, ms('6h'));
}
Expand Down
3 changes: 2 additions & 1 deletion app/controllers/web/my-account/create-domain.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ async function createDomain(ctx, next) {
) {
const names = await Domains.distinct('name', {
'members.user': ctx.state.user._id,
plan: 'free',
plan: config.isSelfHosted ? 'team' : ctx.request.body.plan,
is_global: false
});

Expand Down Expand Up @@ -67,6 +67,7 @@ async function createDomain(ctx, next) {
locale: ctx.locale,
plan: ctx.request.body.plan,
resolver: ctx.resolver,
has_smtp: Boolean(config.isSelfHosted),
...ctx.state.optionalBooleans
});

Expand Down
4 changes: 4 additions & 0 deletions app/controllers/web/my-account/ensure-paid-to-date.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ const config = require('#config');

// eslint-disable-next-line complexity
async function ensurePaidToDate(ctx, next) {
// short-circuit if in self-hosted mode
// as we don't need to check payment
if (config.isSelfHosted) return next();

// if the user has a global domain and they're not an admin
// and they are not on a paid plan or their plan is 30d past due
// then alert them with a toast notification
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/web/my-account/retrieve-domain.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ async function retrieveDomain(ctx, next) {
ctx.state.domain.root_name = parseRootDomain(ctx.state.domain.name);
ctx.state.hasExistingMX = false;
ctx.state.hasExistingTXT = false;
ctx.state.isSelfHosted = config.isSelfHosted;
ctx.state.exchanges = Array.isArray(EXCHANGES) ? EXCHANGES : [EXCHANGES];

//
// only check dns/mx if we're on the setup page
Expand Down
10 changes: 10 additions & 0 deletions app/models/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,16 @@ Users.pre('validate', async function (next) {
Users.pre('save', async function (next) {
const user = this;

// If self-hosted then always set to a date in the future
if (config.isSelfHosted) {
user[config.userFields.planExpiresAt] = dayjs().add(50, 'year').toDate();
return next();
}

// arbitrary block due to stripe spam unresolved in november 2024
if (typeof user.email === 'string' && user.email.startsWith('hbrzi'))
return next(new Error('Try again later'));

// If user has a paid plan then consider their email verified
if (user.plan !== 'free') user[config.userFields.hasVerifiedEmail] = true;

Expand Down
7 changes: 7 additions & 0 deletions app/views/_footer.pug
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,13 @@ footer.mt-auto(aria-label=t("Footer"))
= t("Free Email Webhooks")
else
= t("Email Webhooks")
li: a.d-block.text-white.py-2.py-md-0(
href=isBot(ctx.get("User-Agent")) ? l("/self-hosted") : l("/faq#do-you-support-self-hosting")
)
if isBot(ctx.get("User-Agent"))
= t("Self Hosted")
else
= t("Self Hosted Email")
li: a.d-block.text-white.py-2.py-md-0(
href=l("/faq#do-you-support-bounce-webhooks")
)= t("Bounce Webhooks")
Expand Down
8 changes: 8 additions & 0 deletions app/views/_nav.pug
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,14 @@ nav.navbar(class=navbarClasses.join(" "))
= t("Email API Reference")
else
= t("API Reference")
a.dropdown-item(
href=l("/self-hosted"),
class=ctx.pathWithoutLocale === "/self-hosted" ? "active" : ""
)
if isBot(ctx.get("User-Agent"))
= t("Email Self Hosted")
else
= t("Self Hosted")
a.dropdown-item(
href=isBot(ctx.get("User-Agent")) ? l("/free-email-webhooks") : l("/faq#do-you-support-webhooks"),
class=ctx.pathWithoutLocale === "/free-email-webhooks" ? "active" : ""
Expand Down
64 changes: 34 additions & 30 deletions app/views/my-account/domains/retrieve.pug
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,10 @@ block body
td.align-middle: code.text-themed 10
td.align-middle.text-left.py-3
code#copy-mx1.d-block.text-themed.text-nowrap
| mx1.forwardemail.net
if isSelfHosted
= exchanges[0]
else
| mx1.forwardemail.net
if provider && provider.trailingPeriod
= "."
if !domain.has_mx_record
Expand All @@ -320,37 +323,38 @@ block body
= t("Copy")
if provider && provider.slug && provider.slug === 'cloudflare'
td.align-middle.small.text-center.user-select-none.text-nowrap DNS only
tr
td(class=domain.has_mx_record ? "bg-success" : "bg-danger")
td.align-middle.small
if !domain.root_name || domain.root_name === domain.name
if provider && typeof provider.host === 'string'
if provider.host === ''
i.text-muted.user-select-none= t("None")
if !isSelfHosted
tr
td(class=domain.has_mx_record ? "bg-success" : "bg-danger")
td.align-middle.small
if !domain.root_name || domain.root_name === domain.name
if provider && typeof provider.host === 'string'
if provider.host === ''
i.text-muted.user-select-none= t("None")
else
= provider.host
else
= provider.host
| "@", ".", or leave empty/blank if allowed.
else
| "@", ".", or leave empty/blank if allowed.
else
code.text-themed= domain.name.slice(0, domain.name.lastIndexOf(domain.root_name) - 1)
td.align-middle: strong.px-2 MX
td.align-middle: code.text-themed 10
td.align-middle.text-left.py-3
code#copy-mx2.d-block.text-themed.text-nowrap
| mx2.forwardemail.net
if provider && provider.trailingPeriod
= "."
if !domain.has_mx_record
button.btn.btn-dark.btn-sm.text-nowrap.mt-1(
type="button",
data-toggle="clipboard",
data-clipboard-target="#copy-mx2"
)
i.fa.fa-clipboard
= " "
= t("Copy")
if provider && provider.slug && provider.slug === 'cloudflare'
td.align-middle.small.text-center.user-select-none.text-nowrap DNS only
code.text-themed= domain.name.slice(0, domain.name.lastIndexOf(domain.root_name) - 1)
td.align-middle: strong.px-2 MX
td.align-middle: code.text-themed 10
td.align-middle.text-left.py-3
code#copy-mx2.d-block.text-themed.text-nowrap
| mx2.forwardemail.net
if provider && provider.trailingPeriod
= "."
if !domain.has_mx_record
button.btn.btn-dark.btn-sm.text-nowrap.mt-1(
type="button",
data-toggle="clipboard",
data-clipboard-target="#copy-mx2"
)
i.fa.fa-clipboard
= " "
= t("Copy")
if provider && provider.slug && provider.slug === 'cloudflare'
td.align-middle.small.text-center.user-select-none.text-nowrap DNS only
if domain.plan === 'free' && hasExistingTXT
each record in existingTXT
tr
Expand Down
Loading

0 comments on commit f7660c3

Please sign in to comment.