Compare commits

...

19 commits

Author SHA1 Message Date
977945c72f fix(deps): update module git.happydns.org/checker-sdk-go to v1.7.0
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-30 03:10:51 +00:00
f606e414db checker: recover stale running executions at startup
Some checks are pending
continuous-integration/drone/push Build is running
Mark any execution still in Pending/Running state as Failed when the app
starts, so executions interrupted by a crash or kill no longer appear
stuck "running" forever in the UI.
2026-04-30 10:00:53 +07:00
3d0d6db33f web: add Email Auto-configuration service editor and translations
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-29 18:51:51 +07:00
23c9a00600 docs: document the Email Auto-configuration service 2026-04-29 18:51:51 +07:00
513a646394 api: add Caddy on-demand TLS ask hook for autoconfig hostnames 2026-04-29 18:51:51 +07:00
f6ccba3958 app: wire email auto-configuration usecase and CLI flag 2026-04-29 18:51:51 +07:00
9d3dabc32a api: expose Mozilla Autoconfig and Microsoft Autodiscover endpoints
Adds the public /mail/config-v1.1.xml and /Autodiscover/Autodiscover.xml
HTTP endpoints.
2026-04-29 18:51:51 +07:00
4d81514f4d usecase/emailautoconfig: render Mozilla and Microsoft auto-config XML 2026-04-29 18:51:51 +07:00
b424ac1234 storage: add FindDomainsByName lookup across all users
Adds an unauthenticated domain lookup by FQDN, needed by the upcoming
public email auto-configuration HTTP responders which must serve any
domain hosted by happyDomain regardless of the requesting client.
2026-04-29 18:51:51 +07:00
a9304193c0 services/abstract: add high-level Email Auto-configuration service
Bumps the analyzer weight of the pre-existing "low-level" RFC 6186 service
so the new high-level form is preferred by default; the RFC 6186 service
is kept as an expert escape hatch.
2026-04-29 18:51:51 +07:00
5d06cd91ff model: add EmailAutoconfig usecase interface and MailAutoconfigHost option 2026-04-29 18:51:51 +07:00
4a8ac74ffb web: pre-select rules in RunCheckModal from existing plan
Some checks are pending
continuous-integration/drone/push Build is running
2026-04-29 18:51:51 +07:00
7366281c53 model: Fix too restrictive binding form
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-29 17:21:18 +07:00
0a635ee9f5 web: Sort checker list by alphabetical order
Some checks are pending
continuous-integration/drone/push Build is running
2026-04-29 17:20:16 +07:00
ccb79b081d web: Add editor for svcs.DMARCReport service
All checks were successful
continuous-integration/drone/push Build is passing
Provides a minimal table UI to manage the list of reporting domains
authorised via <reportingdomain>._report._dmarc TXT records, replacing
the Orphan editor fallback.
2026-04-29 14:20:59 +07:00
9931c13543 app: stop background workers before HTTP shutdown 2026-04-29 13:47:27 +07:00
8e36050683 web: Improve error handling when servicesSpecs is not up-to-date 2026-04-29 13:47:27 +07:00
395ea0e292 ci: run go generate in dependency modules + optimize Dockerfile
All checks were successful
continuous-integration/drone/push Build is passing
Some checker dependencies (e.g. checker-caa) embed assets produced by
go:generate directives. Run go generate in every non-main module after
go mod download so embedded files are materialised before build.
2026-04-29 12:59:36 +07:00
74a7aff190 Update module git.happydns.org/checker-dane to v0.2.0 2026-04-29 12:45:18 +07:00
35 changed files with 2492 additions and 40 deletions

View file

@ -13,7 +13,10 @@ steps:
commands:
- sed -i '/npm run build/d;/npm run generate:api/d' web/assets.go web-admin/assets.go
- go install github.com/swaggo/swag/cmd/swag@latest
- go mod download
- go generate ./...
environment:
CGO_ENABLED: 0
- name: update frontend version
image: node:24-alpine
@ -224,7 +227,10 @@ steps:
commands:
- sed -i '/npm run build/d;/npm run generate:api/d' web/assets.go web-admin/assets.go
- go install github.com/swaggo/swag/cmd/swag@latest
- go mod download
- go generate ./...
environment:
CGO_ENABLED: 0
- name: update frontend version
image: node:24-alpine

View file

@ -2,18 +2,25 @@ FROM golang:1-alpine AS gogenerator
WORKDIR /go/src/git.happydns.org/happydomain
# First download dependancies
COPY go.mod go.sum ./
RUN go mod download && \
go install github.com/swaggo/swag/cmd/swag@latest
# Generate go code
COPY checkers ./checkers
COPY cmd ./cmd
COPY tools ./tools
COPY internal ./internal
COPY model ./model
COPY providers ./providers
COPY services ./services
COPY tools ./tools
COPY web/ ./web
COPY web-admin/ ./web-admin
COPY generate.go ./
RUN sed -i '/npm run build/d;/npm run generate:api/d' web/assets.go web-admin/assets.go && \
go install github.com/swaggo/swag/cmd/swag@latest && \
go generate -v ./...
@ -21,7 +28,7 @@ FROM node:24-alpine AS nodebuild
WORKDIR /go/src/git.happydns.org/happydomain
COPY --from=gogenerator docs/ docs/
COPY --from=gogenerator /go/src/git.happydns.org/happydomain/docs/ docs/
COPY web/ web/
RUN yarn config set network-timeout 100000 && \
@ -30,7 +37,7 @@ RUN yarn config set network-timeout 100000 && \
yarn --cwd web --offline build
COPY --from=gogenerator docs-admin/ docs-admin/
COPY --from=gogenerator /go/src/git.happydns.org/happydomain/docs-admin/ docs-admin/
COPY web-admin/ web-admin/
RUN yarn config set network-timeout 100000 && \
@ -52,12 +59,13 @@ COPY --from=gogenerator /go/src/git.happydns.org/happydomain/web/src/lib/dns_rr.
COPY --from=gogenerator /go/src/git.happydns.org/happydomain/internal/usecase/service_specs_dns_types.go internal/usecase/service_specs_dns_types.go
COPY --from=gogenerator /go/src/git.happydns.org/happydomain/docs/ docs/
COPY --from=gogenerator /go/src/git.happydns.org/happydomain/docs-admin/ docs-admin/
COPY checkers ./checkers
COPY cmd ./cmd
COPY tools ./tools
COPY internal ./internal
COPY model ./model
COPY providers ./providers
COPY services ./services
COPY tools ./tools
COPY generate.go go.mod go.sum ./
RUN go build -v -tags netgo,swagger,web -ldflags '-w' ./cmd/happyDomain/

168
docs/email-autoconfig.md Normal file
View file

@ -0,0 +1,168 @@
# Email auto-configuration
happyDomain ships an integrated **Email Auto-configuration** service that lets
users publish IMAP/POP3/SMTP settings for their mail clients via the three
de-facto standards mail clients try in order:
1. **RFC 6186** — DNS SRV records (`_imap._tcp`, `_imaps._tcp`,
`_submission._tcp`, …).
2. **Mozilla Autoconfig**`https://autoconfig.<domain>/mail/config-v1.1.xml`
(Thunderbird).
3. **Microsoft Autodiscover**`https://autodiscover.<domain>/Autodiscover/Autodiscover.xml`
(Outlook).
A single happyDomain service emits the SRV records *and* the CNAMEs for the
two HTTP-based standards. happyDomain itself serves the XML responses for
those CNAMEs, so users get a fully working mail-client auto-configuration
out of the box, without operating an extra Web server.
The HTTP-based standards require valid HTTPS certificates on
`autoconfig.<domain>` and `autodiscover.<domain>`. happyDomain delegates that
to a reverse proxy (Caddy is the recommended choice) that uses **on-demand
TLS** to obtain certificates automatically. happyDomain exposes a small
validation endpoint that Caddy queries before issuing each certificate, so
certificates are only obtained for domains that actually opted into the
service.
## Configuration
happyDomain needs to know the public FQDN where it serves the
auto-configuration XML — that's the target of the `autoconfig.` and
`autodiscover.` CNAMEs the service emits.
| Setting | CLI flag / env | Default |
| ------------------------------------ | --------------------------------------------- | --------------------- |
| Public happyDomain URL | `--externalurl` / `HAPPYDOMAIN_EXTERNAL_URL` | `http://localhost:8081` |
| Public host for autoconfig endpoints | `--mail-autoconfig-host` / `HAPPYDOMAIN_MAIL_AUTOCONFIG_HOST` | derived from `--externalurl` |
If `--mail-autoconfig-host` is left unset, happyDomain uses the host part of
`--externalurl`. The same hostname must be reachable over HTTPS and able to
get a valid certificate for `autoconfig.<user-domain>` and
`autodiscover.<user-domain>` (see the Caddy section below).
## Endpoints exposed by happyDomain
All three are public, rate-limited (30 req/min per client IP), and read-only.
None require authentication.
| Method | Path | Purpose |
| ------- | ----------------------------------- | --------------------------------------------- |
| GET | `/mail/config-v1.1.xml` | Mozilla Autoconfig XML for Thunderbird |
| GET/POST| `/Autodiscover/Autodiscover.xml` | Microsoft Autodiscover XML for Outlook |
| GET/POST| `/autodiscover/autodiscover.xml` | Same, lowercase variant |
| GET | `/api/caddy/ask` | Caddy on-demand TLS validation hook |
The Caddy hook only authorises certificates for `autoconfig.<X>` /
`autodiscover.<X>` where `X` is a domain registered in happyDomain *and* has
the Email Auto-configuration service configured.
## End-user flow
1. User adds their domain to happyDomain (existing flow).
2. From the service catalogue (Email category), the user picks
"Email Auto-configuration".
3. The dedicated form asks for:
- Incoming server: protocol (IMAP/IMAPS/POP3/POP3S), hostname, port,
authentication method.
- Outgoing server: protocol (submission/submissions), hostname, port,
authentication method.
- Discovery toggle (publishes the autoconfig./autodiscover. CNAMEs).
- Optional Microsoft Exchange server.
- Optional branding (display name, username format).
4. Saving the service generates the SRV records, the CNAMEs, and an
`_autodiscover._tcp` SRV. The user applies the diff to the zone as usual.
5. Mail clients now self-configure when fed `user@<domain>`.
## Deploying with Caddy (recommended)
A single Caddy instance can front happyDomain and handle TLS for both the
main UI and the auto-configuration endpoints.
### Caddyfile
```caddyfile
{
# Tell Caddy to ask happyDomain before issuing certificates for
# arbitrary subdomains.
on_demand_tls {
ask https://happydomain.example.com/api/caddy/ask
}
}
# Main happyDomain UI on its own (regular) hostname.
happydomain.example.com {
reverse_proxy happydomain:8081
}
# Catch-all for autoconfig.<X> and autodiscover.<X>.
# Caddy obtains a certificate on-demand for each new <X> only when the
# /api/caddy/ask endpoint authorises it.
https:// {
@autoconfig header_regexp Host ^(?:autoconfig|autodiscover)\.
handle @autoconfig {
reverse_proxy happydomain:8081
}
handle {
respond 404
}
tls {
on_demand
}
}
```
### docker-compose example
```yaml
services:
happydomain:
image: happydomain/happydomain:latest
environment:
HAPPYDOMAIN_EXTERNAL_URL: https://happydomain.example.com
HAPPYDOMAIN_MAIL_AUTOCONFIG_HOST: happydomain.example.com
expose:
- 8081
volumes:
- happydomain-data:/data
caddy:
image: caddy:2
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
volumes:
happydomain-data:
caddy-data:
caddy-config:
```
When a user (say `example.com`) configures the service, their DNS will hold:
```
_imaps._tcp.example.com. 3600 IN SRV 0 1 993 imap.example.com.
_submission._tcp.example.com. 3600 IN SRV 0 1 587 smtp.example.com.
autoconfig.example.com. 3600 IN CNAME happydomain.example.com.
autodiscover.example.com. 3600 IN CNAME happydomain.example.com.
_autodiscover._tcp.example.com. 3600 IN SRV 0 0 443 happydomain.example.com.
```
The first time Thunderbird/Outlook hits
`https://autoconfig.example.com/mail/config-v1.1.xml`, Caddy:
1. Receives the request for an unknown hostname.
2. Calls `https://happydomain.example.com/api/caddy/ask?domain=autoconfig.example.com`.
3. happyDomain checks: parent `example.com` is registered, has the Email
Auto-configuration service → returns 200.
4. Caddy obtains a Let's Encrypt certificate for `autoconfig.example.com` and
reverse-proxies the request to happyDomain.
5. happyDomain renders the Mozilla XML from the user's stored service
config and returns it.
Subsequent requests reuse the cached certificate.

6
go.mod
View file

@ -8,8 +8,8 @@ require (
git.happydns.org/checker-alias v0.1.0
git.happydns.org/checker-authoritative-consistency v0.1.0
git.happydns.org/checker-blacklist v0.1.0
git.happydns.org/checker-caa v0.1.0
git.happydns.org/checker-dane v0.1.3
git.happydns.org/checker-caa v0.2.0
git.happydns.org/checker-dane v0.2.0
git.happydns.org/checker-dangling v0.1.0
git.happydns.org/checker-dav v0.1.0
git.happydns.org/checker-delegation v0.1.0
@ -28,7 +28,7 @@ require (
git.happydns.org/checker-ptr v0.1.0
git.happydns.org/checker-resolver-propagation v0.1.0
git.happydns.org/checker-reverse-zone v0.1.0
git.happydns.org/checker-sdk-go v1.5.0
git.happydns.org/checker-sdk-go v1.7.0
git.happydns.org/checker-sip v0.2.0
git.happydns.org/checker-smtp v0.1.0
git.happydns.org/checker-srv v0.1.0

10
go.sum
View file

@ -14,10 +14,10 @@ git.happydns.org/checker-authoritative-consistency v0.1.0 h1:+0XvJFC7tFVf0Dgruew
git.happydns.org/checker-authoritative-consistency v0.1.0/go.mod h1:hPxEDSyrPq+KY9YU5QoZ1btecw/cU/Miouuacaz4wzk=
git.happydns.org/checker-blacklist v0.1.0 h1:IV44Lxnw0dLBhoyAkAlq9A+hTB5B4RF4vLiW+nX21gg=
git.happydns.org/checker-blacklist v0.1.0/go.mod h1:DRHkpULz8F6dKm0LUErAAQln0x8XByg+/UxbUY46oZk=
git.happydns.org/checker-caa v0.1.0 h1:L0kg9dqdJqmjaPrgbLtBvgEE6+e+7EVSSRPB5pIzNIQ=
git.happydns.org/checker-caa v0.1.0/go.mod h1:7ecPoFRYT0+Fl5DG17Xvz9Xh2alwgEpSSaE2rp0EcT0=
git.happydns.org/checker-dane v0.1.3 h1:9VpQ4FrWJE/O6MZ08FCk1vmHsr3u5V7478als9Y4jl8=
git.happydns.org/checker-dane v0.1.3/go.mod h1:md5SQA8M1QGq9MoXe3QVV+m55I+r8lU4iYx5KzvkbII=
git.happydns.org/checker-caa v0.2.0 h1:KfCXKMDKg4gl5cv4zNPkUnnbWWBVVymj6Cv9FRJmFRY=
git.happydns.org/checker-caa v0.2.0/go.mod h1:7ecPoFRYT0+Fl5DG17Xvz9Xh2alwgEpSSaE2rp0EcT0=
git.happydns.org/checker-dane v0.2.0 h1:itN9q3zfZeiJgibyjUhQsHDr5StLSDk5CgOHBE3OWw8=
git.happydns.org/checker-dane v0.2.0/go.mod h1:GEdoDOO4LdwQQouPgl0JHyOFsMWGSp2mxdU5H9FCses=
git.happydns.org/checker-dangling v0.1.0 h1:gZVyHAKG2U1FXBt7cPnZsr45JQWZ21jlThKhHckb+i8=
git.happydns.org/checker-dangling v0.1.0/go.mod h1:pVvhXkZiKueKhWe878GrN+7BMRrqtJDfTSKLX5eCC5M=
git.happydns.org/checker-dav v0.1.0 h1:7IcviX3IKWdCzzxkILFegOgTrq20EX8CQ+jdfKeP2bs=
@ -56,6 +56,8 @@ git.happydns.org/checker-reverse-zone v0.1.0 h1:hzYPJB/MB09GkZ8dY917bZtzM1yKOCRW
git.happydns.org/checker-reverse-zone v0.1.0/go.mod h1:BaXNcqgrqd+0L3Tg3QuN4kbr5MiWjffz5TGoci7cE/E=
git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY=
git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
git.happydns.org/checker-sdk-go v1.7.0 h1:dSgo2js5mfXluLc6x0WWZ0MQULd9XV2GI9z0Usk+Qgw=
git.happydns.org/checker-sdk-go v1.7.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
git.happydns.org/checker-sip v0.2.0 h1:ZBYZO/ocBhdFKM70xEZOL/v4OJ36BWU6wPVBcS871NM=
git.happydns.org/checker-sip v0.2.0/go.mod h1:B32Nq41GrRWHvWzueZXLFsDSqDvndocaGdEl59meMrM=
git.happydns.org/checker-smtp v0.1.0 h1:Sa3adUCXvuI83p/clwq3+/M+6l+WiBppqcuw+nVtdP0=

View file

@ -0,0 +1,185 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"encoding/xml"
"errors"
"io"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/miekg/dns"
"git.happydns.org/happyDomain/model"
)
// EmailAutoconfigController serves the public mail-client auto-configuration
// endpoints used by Thunderbird (Mozilla Autoconfig) and Outlook (Microsoft
// Autodiscover), plus the Caddy on-demand TLS validation hook.
type EmailAutoconfigController struct {
uc happydns.EmailAutoconfigUsecase
}
// NewEmailAutoconfigController constructs an EmailAutoconfigController.
func NewEmailAutoconfigController(uc happydns.EmailAutoconfigUsecase) *EmailAutoconfigController {
return &EmailAutoconfigController{uc: uc}
}
// resolveDomain extracts the domain to look up. Priority: emailaddress query
// param → Host header (with the autoconfig./autodiscover. prefix stripped).
func resolveDomain(c *gin.Context, emailParamNames ...string) string {
for _, name := range emailParamNames {
if v := c.Query(name); v != "" {
if at := strings.LastIndex(v, "@"); at >= 0 {
return v[at+1:]
}
}
}
host := c.Request.Host
if i := strings.IndexByte(host, ':'); i >= 0 {
host = host[:i]
}
return host
}
// MozillaAutoconfig serves the Thunderbird config-v1.1.xml format.
//
// @Summary Mail-client auto-configuration (Mozilla Autoconfig)
// @Description Returns the Thunderbird-style XML configuration for the requested domain.
// @Tags email-autoconfig
// @Produce application/xml
// @Param emailaddress query string false "Email address (used to derive the domain)"
// @Success 200 {string} string
// @Failure 404 {object} happydns.ErrorResponse
// @Router /mail/config-v1.1.xml [get]
func (ec *EmailAutoconfigController) MozillaAutoconfig(c *gin.Context) {
domain := resolveDomain(c, "emailaddress")
if domain == "" {
c.AbortWithStatusJSON(http.StatusBadRequest, happydns.ErrorResponse{Message: "missing domain"})
return
}
body, err := ec.uc.MozillaConfig(dns.Fqdn(domain), c.Query("emailaddress"))
if err != nil {
if errors.Is(err, happydns.ErrNotFound) {
c.AbortWithStatusJSON(http.StatusNotFound, happydns.ErrorResponse{Message: "no auto-configuration found for this domain"})
return
}
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
return
}
c.Data(http.StatusOK, "application/xml; charset=utf-8", body)
}
// autodiscoverRequest is the (very small) subset of Outlook's POST body we
// actually need to read.
type autodiscoverRequest struct {
XMLName xml.Name `xml:"Autodiscover"`
Request struct {
EMailAddress string `xml:"EMailAddress"`
} `xml:"Request"`
}
// MSAutodiscover serves the Microsoft Autodiscover POX format. Outlook may
// hit this endpoint with either GET or POST; both are handled identically
// from happyDomain's perspective (we only need the email address).
//
// @Summary Mail-client auto-configuration (Microsoft Autodiscover)
// @Description Returns the Outlook-style XML configuration for the requested domain.
// @Tags email-autoconfig
// @Produce application/xml
// @Success 200 {string} string
// @Failure 404 {object} happydns.ErrorResponse
// @Router /Autodiscover/Autodiscover.xml [post]
func (ec *EmailAutoconfigController) MSAutodiscover(c *gin.Context) {
emailAddress := c.Query("emailaddress")
if emailAddress == "" {
emailAddress = c.Query("Email")
}
if c.Request.Method == http.MethodPost && c.Request.Body != nil {
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 64*1024))
if err == nil && len(body) > 0 {
var req autodiscoverRequest
if xmlErr := xml.Unmarshal(body, &req); xmlErr == nil && req.Request.EMailAddress != "" {
emailAddress = req.Request.EMailAddress
}
}
}
domain := resolveDomain(c, "emailaddress", "Email")
if at := strings.LastIndex(emailAddress, "@"); at >= 0 {
domain = emailAddress[at+1:]
}
if domain == "" {
c.AbortWithStatusJSON(http.StatusBadRequest, happydns.ErrorResponse{Message: "missing domain"})
return
}
body, err := ec.uc.AutodiscoverConfig(dns.Fqdn(domain), emailAddress)
if err != nil {
if errors.Is(err, happydns.ErrNotFound) {
c.AbortWithStatusJSON(http.StatusNotFound, happydns.ErrorResponse{Message: "no auto-configuration found for this domain"})
return
}
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
return
}
c.Data(http.StatusOK, "application/xml; charset=utf-8", body)
}
// CaddyAsk implements the Caddy on-demand TLS "ask" endpoint. Caddy treats
// any 2xx response as "go ahead and issue the cert" and any other status as
// "deny". The endpoint is scoped strictly to autoconfig./autodiscover.
// subdomains so happyDomain never authorises certs for arbitrary domains.
//
// @Summary Caddy on-demand TLS validation
// @Description Returns 200 when happyDomain hosts the email auto-configuration for the requested domain.
// @Tags email-autoconfig
// @Param domain query string true "FQDN Caddy is about to obtain a certificate for"
// @Success 200
// @Failure 400
// @Failure 404
// @Router /caddy/ask [get]
func (ec *EmailAutoconfigController) CaddyAsk(c *gin.Context) {
domain := strings.TrimSpace(c.Query("domain"))
if domain == "" {
c.AbortWithStatus(http.StatusBadRequest)
return
}
managed, err := ec.uc.IsManaged(domain)
if err != nil {
c.AbortWithStatus(http.StatusInternalServerError)
return
}
if !managed {
c.AbortWithStatus(http.StatusNotFound)
return
}
c.Status(http.StatusOK)
}

View file

@ -0,0 +1,78 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package route
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
ratelimit "github.com/JGLTechnologies/gin-rate-limit"
"git.happydns.org/happyDomain/internal/api/controller"
happydns "git.happydns.org/happyDomain/model"
)
// DeclareEmailAutoconfigRoutes wires the public HTTP endpoints for mail-client
// auto-configuration onto the provided base and API route groups. baseRoutes
// receives the well-known XML paths dictated by the standards (Mozilla and
// Microsoft); apiRoutes receives the Caddy validation hook.
func DeclareEmailAutoconfigRoutes(baseRoutes, apiRoutes *gin.RouterGroup, uc happydns.EmailAutoconfigUsecase) {
if uc == nil {
return
}
store := ratelimit.InMemoryStore(&ratelimit.InMemoryOptions{
Rate: time.Minute,
Limit: 30,
})
rl := ratelimit.RateLimiter(store, &ratelimit.Options{
ErrorHandler: func(c *gin.Context, info ratelimit.Info) {
c.AbortWithStatusJSON(http.StatusTooManyRequests, happydns.ErrorResponse{
Message: "Too many requests. Please try again later.",
})
},
KeyFunc: func(c *gin.Context) string {
return c.ClientIP()
},
})
ctrl := controller.NewEmailAutoconfigController(uc)
// Mozilla Autoconfig: clients fetch GET https://autoconfig.<domain>/mail/config-v1.1.xml
baseRoutes.GET("/mail/config-v1.1.xml", rl, ctrl.MozillaAutoconfig)
// Microsoft Autodiscover: Outlook hits both GET and POST, with two
// common spellings of the path.
for _, path := range []string{
"/Autodiscover/Autodiscover.xml",
"/autodiscover/autodiscover.xml",
"/AutoDiscover/AutoDiscover.xml",
} {
baseRoutes.GET(path, rl, ctrl.MSAutodiscover)
baseRoutes.POST(path, rl, ctrl.MSAutodiscover)
}
// Caddy on-demand TLS ask hook.
apiRoutes.GET("/caddy/ask", rl, ctrl.CaddyAsk)
}

View file

@ -44,6 +44,7 @@ type Dependencies struct {
Domain happydns.DomainUsecase
DomainInfo happydns.DomainInfoUsecase
DomainLog happydns.DomainLogUsecase
EmailAutoconfig happydns.EmailAutoconfigUsecase
FailureTracker happydns.FailureTracker
Provider happydns.ProviderUsecase
ProviderSettings happydns.ProviderSettingsUsecase
@ -120,6 +121,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
},
})
DeclareDomainInfoRoutes(apiRoutes.Group("/domaininfo/:domain", domainInfoRLMiddleware), dep.DomainInfo)
DeclareEmailAutoconfigRoutes(baseRoutes, apiRoutes, dep.EmailAutoconfig)
DeclareProviderSpecsRoutes(apiRoutes, dep.ProviderSpecs)
DeclareRegistrationRoutes(apiRoutes, dep.AuthUser, dep.CaptchaVerifier)
DeclareResolverRoutes(apiRoutes, dep.Resolver)

View file

@ -118,6 +118,6 @@ func (app *Admin) Stop() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := app.srv.Shutdown(ctx); err != nil {
log.Fatal("Admin Server Shutdown:", err)
log.Printf("Admin Server Shutdown: %v", err)
}
}

View file

@ -42,6 +42,7 @@ import (
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
domainUC "git.happydns.org/happyDomain/internal/usecase/domain"
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
emailAutoconfigUC "git.happydns.org/happyDomain/internal/usecase/emailautoconfig"
"git.happydns.org/happyDomain/internal/usecase/orchestrator"
providerUC "git.happydns.org/happyDomain/internal/usecase/provider"
serviceUC "git.happydns.org/happyDomain/internal/usecase/service"
@ -51,6 +52,7 @@ import (
zoneServiceUC "git.happydns.org/happyDomain/internal/usecase/zone_service"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/pkg/domaininfo"
"git.happydns.org/happyDomain/services/abstract"
"git.happydns.org/happyDomain/web"
)
@ -60,6 +62,7 @@ type Usecases struct {
domain happydns.DomainUsecase
domainInfo happydns.DomainInfoUsecase
domainLog happydns.DomainLogUsecase
emailAutoconfig happydns.EmailAutoconfigUsecase
provider happydns.ProviderUsecase
providerAdmin happydns.ProviderUsecase
providerSpecs happydns.ProviderSpecsUsecase
@ -234,6 +237,15 @@ func (app *App) initUsecases() {
)
app.usecases.domainLog = domainLogService
// Email auto-configuration: derive the autoconfig CNAME target from
// MailAutoconfigHost (if set) or fall back to ExternalURL.Host.
autoconfigHost := app.cfg.MailAutoconfigHost
if autoconfigHost == "" {
autoconfigHost = app.cfg.ExternalURL.Hostname()
}
abstract.SetAutoconfigHost(autoconfigHost)
app.usecases.emailAutoconfig = emailAutoconfigUC.NewUsecase(app.store, zoneService.GetZoneUC)
domainService := domainUC.NewService(
app.store,
providerAdminService,
@ -351,6 +363,7 @@ func (app *App) setupRouter() {
Domain: app.usecases.domain,
DomainInfo: app.usecases.domainInfo,
DomainLog: app.usecases.domainLog,
EmailAutoconfig: app.usecases.emailAutoconfig,
FailureTracker: app.failureTracker,
Provider: app.usecases.provider,
ProviderSettings: app.usecases.providerSettings,
@ -390,6 +403,19 @@ func (app *App) Start() {
go app.insights.Run()
}
// Reconcile executions left "running" by a previous process that
// crashed or was killed mid-run, before the scheduler starts queuing
// new work.
if recoverer, ok := app.usecases.checkerEngine.(interface {
RecoverStaleExecutions(ctx context.Context) (int, error)
}); ok {
if n, err := recoverer.RecoverStaleExecutions(context.Background()); err != nil {
log.Printf("CheckerEngine: failed to recover stale executions: %v", err)
} else if n > 0 {
log.Printf("CheckerEngine: recovered %d stale execution(s) from previous run", n)
}
}
if app.usecases.checkerScheduler != nil {
app.usecases.checkerScheduler.Start(context.Background())
}
@ -409,12 +435,9 @@ func (app *App) Start() {
}
func (app *App) Stop() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := app.srv.Shutdown(ctx); err != nil {
log.Fatal("Server Shutdown:", err)
}
// Stop background workers first so they don't dispatch new work while
// the HTTP server is draining. Each Stop() cancels its context and
// waits for in-flight goroutines to return.
if app.usecases.checkerScheduler != nil {
app.usecases.checkerScheduler.Stop()
}
@ -427,6 +450,14 @@ func (app *App) Stop() {
app.usecases.checkerUserGater.Stop()
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := app.srv.Shutdown(ctx); err != nil {
// Don't log.Fatal here: that would skip the storage/insights
// cleanup below and risk leaving state on disk inconsistent.
log.Printf("Server Shutdown: %v", err)
}
// Close storage
if app.store != nil {
app.store.Close()

View file

@ -262,6 +262,11 @@ func (s *instrumentedStorage) DeleteZone(zoneid happydns.Identifier) (err error)
return s.inner.DeleteZone(zoneid)
}
func (s *instrumentedStorage) FindDomainsByName(fqdn string) (ret []*happydns.Domain, err error) {
defer observe("get", "domain")(&err)
return s.inner.FindDomainsByName(fqdn)
}
func (s *instrumentedStorage) GetAuthUser(id happydns.Identifier) (ret *happydns.UserAuth, err error) {
defer observe("get", "authuser")(&err)
return s.inner.GetAuthUser(id)

View file

@ -65,6 +65,8 @@ func declareFlags(o *happydns.Options) {
flag.StringVar(&o.MailSMTPPassword, "mail-smtp-password", o.MailSMTPPassword, "Password associated with the given username for SMTP authentication")
flag.BoolVar(&o.MailSMTPTLSSNoVerify, "mail-smtp-tls-no-verify", o.MailSMTPTLSSNoVerify, "Do not verify certificate validity on SMTP connection")
flag.StringVar(&o.MailAutoconfigHost, "mail-autoconfig-host", o.MailAutoconfigHost, "Public FQDN serving Mozilla Autoconfig and Microsoft Autodiscover (defaults to externalurl host)")
flag.StringVar(&o.CaptchaProvider, "captcha-provider", o.CaptchaProvider, "Captcha provider to use for bot protection (altcha, hcaptcha, recaptchav2, turnstile, or empty to disable)")
flag.IntVar(&o.CaptchaLoginThreshold, "captcha-login-threshold", 3, "Number of failed login attempts before captcha is required (0 = always require when provider configured)")

View file

@ -26,6 +26,8 @@ import (
"errors"
"fmt"
"github.com/miekg/dns"
"git.happydns.org/happyDomain/model"
)
@ -95,6 +97,35 @@ func (s *KVStorage) GetDomainByDN(u *happydns.User, dn string) ([]*happydns.Doma
return ret, nil
}
func (s *KVStorage) FindDomainsByName(fqdn string) ([]*happydns.Domain, error) {
target := dns.Fqdn(fqdn)
iter := s.db.Search("domain-")
defer iter.Release()
var ret []*happydns.Domain
for iter.Next() {
var d happydns.Domain
if err := s.db.DecodeData(iter.Value(), &d); err != nil {
return nil, err
}
if d.DomainName == target {
cp := d
ret = append(ret, &cp)
}
}
if err := iter.Err(); err != nil {
return nil, err
}
if len(ret) == 0 {
return nil, happydns.ErrNotFound
}
return ret, nil
}
func (s *KVStorage) CreateDomain(z *happydns.Domain) error {
key, id, err := s.db.FindIdentifierKey("domain-")
if err != nil {

View file

@ -295,6 +295,39 @@ func (e *checkerEngine) runPipeline(ctx context.Context, def *happydns.CheckerDe
return result, eval, nil
}
// RecoverStaleExecutions scans all executions and marks any still in Pending
// or Running state as Failed. It is intended to be called at startup to
// reconcile state left over from a previous process that crashed or was
// killed mid-execution: without it, the affected executions would remain
// "running" forever in the UI. Returns the number of executions updated.
func (e *checkerEngine) RecoverStaleExecutions(ctx context.Context) (int, error) {
iter, err := e.execStore.ListAllExecutions()
if err != nil {
return 0, fmt.Errorf("listing executions: %w", err)
}
defer iter.Close()
n := 0
for iter.Next() {
exec := iter.Item()
if exec.Status != happydns.ExecutionPending && exec.Status != happydns.ExecutionRunning {
continue
}
endTime := time.Now()
exec.Status = happydns.ExecutionFailed
exec.EndedAt = &endTime
if exec.Error == "" {
exec.Error = "execution interrupted by server restart"
}
if err := e.execStore.UpdateExecution(exec); err != nil {
log.Printf("CheckerEngine: failed to recover stale execution %s: %v", exec.Id.String(), err)
continue
}
n++
}
return n, nil
}
// RelatedLookup exposes the engine's Related resolver so controllers can
// build ReportContexts with cross-checker observations pre-resolved. Returns
// nil when discovery storage is not wired.

View file

@ -43,6 +43,11 @@ type DomainStorage interface {
// GetDomainByDN is like GetDomain but look for the domain name instead of identifier.
GetDomainByDN(user *happydns.User, fqdn string) ([]*happydns.Domain, error)
// FindDomainsByName looks up Domains by FQDN across all users (no
// ownership filter). Used by unauthenticated endpoints like the
// email auto-configuration HTTP responders.
FindDomainsByName(fqdn string) ([]*happydns.Domain, error)
// CreateDomain creates a record in the database for the given Domain.
CreateDomain(domain *happydns.Domain) error

View file

@ -0,0 +1,167 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package emailautoconfig
import (
"encoding/xml"
"strings"
"git.happydns.org/happyDomain/services/abstract"
)
// msAutodiscover matches Microsoft's POX (Plain Old XML) Autodiscover
// response schema. See https://learn.microsoft.com/en-us/exchange/architecture/client-access/autodiscover
type msAutodiscover struct {
XMLName xml.Name `xml:"http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006 Autodiscover"`
Response msResponse `xml:"http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a Response"`
}
type msResponse struct {
User *msUser `xml:"User,omitempty"`
Account *msAccount `xml:"Account,omitempty"`
}
type msUser struct {
DisplayName string `xml:"DisplayName,omitempty"`
}
type msAccount struct {
AccountType string `xml:"AccountType"`
Action string `xml:"Action"`
Protocols []msProtocol `xml:"Protocol"`
}
type msProtocol struct {
Type string `xml:"Type"`
Server string `xml:"Server"`
Port uint16 `xml:"Port"`
LoginName string `xml:"LoginName,omitempty"`
DomainName string `xml:"DomainName,omitempty"`
SSL string `xml:"SSL"`
Encryption string `xml:"Encryption,omitempty"`
SPA string `xml:"SPA,omitempty"`
AuthRequired string `xml:"AuthRequired"`
}
// msAutodiscoverIncomingType maps happyDomain incoming protocol identifiers
// to Microsoft Type vocabulary.
func msAutodiscoverIncomingType(protocol string) string {
switch protocol {
case "imap", "imaps":
return "IMAP"
case "pop3", "pop3s":
return "POP3"
}
return strings.ToUpper(protocol)
}
// msAutodiscoverSSL returns "on" when the protocol uses TLS on connect,
// "off" otherwise.
func msAutodiscoverSSL(protocol string) string {
switch protocol {
case "imaps", "pop3s", "submissions":
return "on"
}
return "off"
}
// msAutodiscoverEncryption returns the Encryption value for SMTP.
// "TLS" maps to STARTTLS in Outlook; "SSL" maps to TLS-on-connect.
func msAutodiscoverEncryption(protocol string) string {
switch protocol {
case "submissions":
return "SSL"
case "submission":
return "TLS"
}
return "None"
}
func msLoginName(s *abstract.EmailAutoConfig) string {
if s.UsernameFormat == "" {
return "%EMAILADDRESS%"
}
return s.UsernameFormat
}
// RenderAutodiscoverXML returns a serialised Microsoft Autodiscover response
// for the given EmailAutoConfig service.
func RenderAutodiscoverXML(s *abstract.EmailAutoConfig, domainName, emailAddress string) ([]byte, error) {
loginName := msLoginName(s)
resp := msAutodiscover{
Response: msResponse{
Account: &msAccount{
AccountType: "email",
Action: "settings",
},
},
}
if s.DisplayName != "" {
resp.Response.User = &msUser{DisplayName: s.DisplayName}
}
if host := s.IncomingHost(); host != "" {
proto := s.IncomingType()
resp.Response.Account.Protocols = append(resp.Response.Account.Protocols, msProtocol{
Type: msAutodiscoverIncomingType(proto),
Server: host,
Port: s.IncomingPort(),
LoginName: loginName,
DomainName: domainName,
SSL: msAutodiscoverSSL(proto),
SPA: "off",
AuthRequired: "on",
})
}
if host := s.OutgoingHost(); host != "" {
proto := s.OutgoingType()
resp.Response.Account.Protocols = append(resp.Response.Account.Protocols, msProtocol{
Type: "SMTP",
Server: host,
Port: s.OutgoingPort(),
LoginName: loginName,
DomainName: domainName,
SSL: msAutodiscoverSSL(proto),
Encryption: msAutodiscoverEncryption(proto),
SPA: "off",
AuthRequired: "on",
})
}
if s.ExchangeServer != "" {
resp.Response.Account.Protocols = append(resp.Response.Account.Protocols, msProtocol{
Type: "EXCH",
Server: s.ExchangeServer,
SSL: "on",
AuthRequired: "on",
})
}
body, err := xml.MarshalIndent(resp, "", " ")
if err != nil {
return nil, err
}
return append([]byte(xml.Header), body...), nil
}

View file

@ -0,0 +1,155 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package emailautoconfig
import (
"encoding/xml"
"git.happydns.org/happyDomain/services/abstract"
)
// mozillaClientConfig matches the Thunderbird auto-configuration format
// (config-v1.1.xml). See https://wiki.mozilla.org/Thunderbird:Autoconfiguration:ConfigFileFormat
type mozillaClientConfig struct {
XMLName xml.Name `xml:"clientConfig"`
Version string `xml:"version,attr"`
EmailProvider mozillaEmailProvider `xml:"emailProvider"`
}
type mozillaEmailProvider struct {
ID string `xml:"id,attr"`
Domain string `xml:"domain"`
DisplayName string `xml:"displayName,omitempty"`
DisplayShortName string `xml:"displayShortName,omitempty"`
IncomingServer *mozillaIncomingServer `xml:"incomingServer,omitempty"`
OutgoingServer *mozillaOutgoingServer `xml:"outgoingServer,omitempty"`
}
type mozillaIncomingServer struct {
Type string `xml:"type,attr"`
Hostname string `xml:"hostname"`
Port uint16 `xml:"port"`
SocketType string `xml:"socketType"`
Username string `xml:"username"`
Authentication string `xml:"authentication,omitempty"`
}
type mozillaOutgoingServer struct {
Type string `xml:"type,attr"`
Hostname string `xml:"hostname"`
Port uint16 `xml:"port"`
SocketType string `xml:"socketType"`
Username string `xml:"username"`
Authentication string `xml:"authentication,omitempty"`
}
// mozillaSocketType returns the socketType value Thunderbird expects for a
// happyDomain protocol identifier. Plain protocols become "plain", TLS-on-
// connect become "SSL", and bare submission becomes "STARTTLS" (RFC 8314
// effectively deprecates plain submission).
func mozillaSocketType(protocol string) string {
switch protocol {
case "imaps", "pop3s", "submissions":
return "SSL"
case "submission":
return "STARTTLS"
default:
return "plain"
}
}
// mozillaIncomingType maps happyDomain protocol identifiers to the type
// attribute Thunderbird expects on <incomingServer>.
func mozillaIncomingType(protocol string) string {
switch protocol {
case "imap", "imaps":
return "imap"
case "pop3", "pop3s":
return "pop3"
}
return protocol
}
// mozillaAuthentication maps happyDomain auth identifiers to Mozilla
// vocabulary. Most overlap; the empty default becomes "password-cleartext".
func mozillaAuthentication(auth string) string {
if auth == "" {
return "password-cleartext"
}
return auth
}
func mozillaUsernameFormat(s *abstract.EmailAutoConfig) string {
if s.UsernameFormat == "" {
return "%EMAILADDRESS%"
}
return s.UsernameFormat
}
// RenderMozillaXML returns a serialised Thunderbird config file for the
// given EmailAutoConfig service.
func RenderMozillaXML(s *abstract.EmailAutoConfig, domainName, emailAddress string) ([]byte, error) {
id := domainName
if id == "" {
id = emailAddress
}
cfg := mozillaClientConfig{
Version: "1.1",
EmailProvider: mozillaEmailProvider{
ID: id,
Domain: domainName,
DisplayName: s.DisplayName,
DisplayShortName: s.DisplayShortName,
},
}
if host := s.IncomingHost(); host != "" {
proto := s.IncomingType()
cfg.EmailProvider.IncomingServer = &mozillaIncomingServer{
Type: mozillaIncomingType(proto),
Hostname: host,
Port: s.IncomingPort(),
SocketType: mozillaSocketType(proto),
Username: mozillaUsernameFormat(s),
Authentication: mozillaAuthentication(s.IncomingAuth),
}
}
if host := s.OutgoingHost(); host != "" {
proto := s.OutgoingType()
cfg.EmailProvider.OutgoingServer = &mozillaOutgoingServer{
Type: "smtp",
Hostname: host,
Port: s.OutgoingPort(),
SocketType: mozillaSocketType(proto),
Username: mozillaUsernameFormat(s),
Authentication: mozillaAuthentication(s.OutgoingAuth),
}
}
body, err := xml.MarshalIndent(cfg, "", " ")
if err != nil {
return nil, err
}
return append([]byte(xml.Header), body...), nil
}

View file

@ -0,0 +1,144 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package emailautoconfig serves the public mail-client auto-configuration
// HTTP endpoints (Mozilla Autoconfig + Microsoft Autodiscover) and the Caddy
// on-demand TLS validation hook.
package emailautoconfig
import (
"errors"
"strings"
"github.com/miekg/dns"
"git.happydns.org/happyDomain/services/abstract"
"git.happydns.org/happyDomain/model"
)
// DomainFinder looks up Domains by FQDN across all users.
type DomainFinder interface {
FindDomainsByName(fqdn string) ([]*happydns.Domain, error)
}
// ZoneGetter retrieves a Zone by its identifier.
type ZoneGetter interface {
Get(zoneID happydns.Identifier) (*happydns.Zone, error)
}
// Usecase implements happydns.EmailAutoconfigUsecase.
type Usecase struct {
domains DomainFinder
zones ZoneGetter
}
// NewUsecase constructs an Usecase wired to the given storage adapters.
func NewUsecase(domains DomainFinder, zones ZoneGetter) *Usecase {
return &Usecase{domains: domains, zones: zones}
}
// stripDiscoveryPrefix removes a leading "autoconfig." or "autodiscover."
// from the given FQDN, returning the parent domain. If the prefix is absent,
// the original FQDN is returned unchanged.
func stripDiscoveryPrefix(fqdn string) string {
fqdn = dns.Fqdn(fqdn)
for _, prefix := range []string{"autoconfig.", "autodiscover."} {
if strings.HasPrefix(fqdn, prefix) {
return fqdn[len(prefix):]
}
}
return fqdn
}
// findService walks every owner of the given parent domain, loads the latest
// zone, and returns the first EmailAutoConfig service found at the apex.
//
// Returns happydns.ErrNotFound if no domain matches or none has the service.
func (uc *Usecase) findService(parentFQDN string) (*abstract.EmailAutoConfig, *happydns.Domain, error) {
domains, err := uc.domains.FindDomainsByName(parentFQDN)
if err != nil {
return nil, nil, err
}
for _, d := range domains {
if len(d.ZoneHistory) == 0 {
continue
}
zone, err := uc.zones.Get(d.ZoneHistory[0])
if err != nil {
continue
}
for _, services := range zone.Services {
for _, s := range services {
if ec, ok := s.Service.(*abstract.EmailAutoConfig); ok {
return ec, d, nil
}
}
}
}
return nil, nil, happydns.ErrNotFound
}
// IsManaged reports whether happyDomain hosts the email auto-configuration
// for the given FQDN. Used by the Caddy on-demand TLS ask endpoint.
func (uc *Usecase) IsManaged(fqdn string) (bool, error) {
fqdn = dns.Fqdn(fqdn)
if !strings.HasPrefix(fqdn, "autoconfig.") && !strings.HasPrefix(fqdn, "autodiscover.") {
return false, nil
}
parent := stripDiscoveryPrefix(fqdn)
_, _, err := uc.findService(parent)
if err != nil {
if errors.Is(err, happydns.ErrNotFound) {
return false, nil
}
return false, err
}
return true, nil
}
// MozillaConfig renders the Thunderbird-style XML for the given FQDN.
// emailAddress is optional and only used for the <emailProvider id=...>
// attribute when the domain itself isn't enough.
func (uc *Usecase) MozillaConfig(domainFQDN, emailAddress string) ([]byte, error) {
parent := stripDiscoveryPrefix(domainFQDN)
svc, _, err := uc.findService(parent)
if err != nil {
return nil, err
}
bareDomain := strings.TrimSuffix(parent, ".")
return RenderMozillaXML(svc, bareDomain, emailAddress)
}
// AutodiscoverConfig renders the Outlook-style XML for the given FQDN.
func (uc *Usecase) AutodiscoverConfig(domainFQDN, emailAddress string) ([]byte, error) {
parent := stripDiscoveryPrefix(domainFQDN)
svc, _, err := uc.findService(parent)
if err != nil {
return nil, err
}
bareDomain := strings.TrimSuffix(parent, ".")
return RenderAutodiscoverXML(svc, bareDomain, emailAddress)
}

View file

@ -0,0 +1,120 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package emailautoconfig
import (
"strings"
"testing"
"github.com/miekg/dns"
"git.happydns.org/happyDomain/services/abstract"
)
func sampleService() *abstract.EmailAutoConfig {
return &abstract.EmailAutoConfig{
DisplayName: "Example Mail",
DisplayShortName: "Example",
IncomingAuth: "password-cleartext",
OutgoingAuth: "password-cleartext",
UsernameFormat: "%EMAILADDRESS%",
IncomingSRV: &dns.SRV{
Hdr: dns.RR_Header{Name: "_imaps._tcp", Rrtype: dns.TypeSRV, Class: dns.ClassINET, Ttl: 3600},
Weight: 1,
Port: 993,
Target: "imap.example.com.",
},
OutgoingSRV: &dns.SRV{
Hdr: dns.RR_Header{Name: "_submission._tcp", Rrtype: dns.TypeSRV, Class: dns.ClassINET, Ttl: 3600},
Weight: 1,
Port: 587,
Target: "smtp.example.com.",
},
}
}
func TestRenderMozillaXML(t *testing.T) {
body, err := RenderMozillaXML(sampleService(), "example.com", "user@example.com")
if err != nil {
t.Fatalf("RenderMozillaXML: %v", err)
}
out := string(body)
for _, want := range []string{
`<?xml version="1.0"`,
`<clientConfig version="1.1">`,
`<emailProvider id="example.com">`,
`<domain>example.com</domain>`,
`<displayName>Example Mail</displayName>`,
`<incomingServer type="imap">`,
`<hostname>imap.example.com</hostname>`,
`<port>993</port>`,
`<socketType>SSL</socketType>`,
`<outgoingServer type="smtp">`,
`<socketType>STARTTLS</socketType>`,
`<port>587</port>`,
} {
if !strings.Contains(out, want) {
t.Errorf("Mozilla XML missing %q\nOutput:\n%s", want, out)
}
}
}
func TestRenderAutodiscoverXML(t *testing.T) {
body, err := RenderAutodiscoverXML(sampleService(), "example.com", "user@example.com")
if err != nil {
t.Fatalf("RenderAutodiscoverXML: %v", err)
}
out := string(body)
for _, want := range []string{
`<Autodiscover`,
`<AccountType>email</AccountType>`,
`<Action>settings</Action>`,
`<Type>IMAP</Type>`,
`<Server>imap.example.com</Server>`,
`<Port>993</Port>`,
`<SSL>on</SSL>`,
`<Type>SMTP</Type>`,
`<Port>587</Port>`,
`<Encryption>TLS</Encryption>`,
} {
if !strings.Contains(out, want) {
t.Errorf("Autodiscover XML missing %q\nOutput:\n%s", want, out)
}
}
}
func TestStripDiscoveryPrefix(t *testing.T) {
for _, tc := range []struct {
in, want string
}{
{"autoconfig.example.com", "example.com."},
{"autodiscover.example.com.", "example.com."},
{"example.com", "example.com."},
{"www.example.com", "www.example.com."},
} {
if got := stripDiscoveryPrefix(tc.in); got != tc.want {
t.Errorf("stripDiscoveryPrefix(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}

View file

@ -143,9 +143,9 @@ type CheckerOptionsPositional struct {
// CheckPlan is an optional user override for a checker on a specific target.
type CheckPlan struct {
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
CheckerID string `json:"checkerId" binding:"required" readonly:"true"`
Target CheckTarget `json:"target" binding:"required" readonly:"true"`
Id Identifier `json:"id" swaggertype:"string" validate:"required" readonly:"true"`
CheckerID string `json:"checkerId" validate:"required" readonly:"true"`
Target CheckTarget `json:"target" validate:"required" readonly:"true"`
Interval *time.Duration `json:"interval,omitempty" swaggertype:"integer"`
Enabled map[string]bool `json:"enabled,omitempty"`
}

View file

@ -63,6 +63,11 @@ type Options struct {
// ...), when it needs to use complete URL, not only relative parts.
ExternalURL url.URL
// MailAutoconfigHost is the public FQDN that the email auto-configuration
// service should target with autoconfig./autodiscover. CNAMEs. When empty,
// it falls back to ExternalURL.Host.
MailAutoconfigHost string
// JWTSecretKey stores the private key to sign and verify JWT tokens.
JWTSecretKey []byte

45
model/email_autoconfig.go Normal file
View file

@ -0,0 +1,45 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package happydns
// EmailAutoconfigUsecase serves the public mail-client auto-configuration
// endpoints (Mozilla Autoconfig + Microsoft Autodiscover) and the Caddy
// on-demand TLS validation hook.
//
// All methods take fully-qualified domain names. The usecase looks up the
// owning Domain in storage, finds the latest Zone, and reads the
// EmailAutoConfig service body to render the appropriate response.
type EmailAutoconfigUsecase interface {
// IsManaged returns true if the given FQDN is hosted by happyDomain
// for the email auto-configuration purpose. It strips an
// "autoconfig." or "autodiscover." prefix and checks that the parent
// domain has a configured EmailAutoConfig service.
IsManaged(fqdn string) (bool, error)
// MozillaConfig renders the Thunderbird-style XML for the given
// domain. emailAddress may be empty.
MozillaConfig(domainFQDN, emailAddress string) ([]byte, error)
// AutodiscoverConfig renders the Outlook-style XML for the given
// domain. emailAddress may be empty.
AutodiscoverConfig(domainFQDN, emailAddress string) ([]byte, error)
}

View file

@ -0,0 +1,330 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package abstract
import (
"fmt"
"strings"
"sync"
"github.com/miekg/dns"
svc "git.happydns.org/happyDomain/internal/service"
"git.happydns.org/happyDomain/model"
)
// autoconfigHostMu guards autoconfigHost. The host is set once at app
// startup, but tests may rewrite it.
var (
autoconfigHostMu sync.RWMutex
autoconfigHost string
)
// SetAutoconfigHost configures the FQDN that autoconfig.<domain> and
// autodiscover.<domain> CNAMEs should point to. Called by the app at startup
// from the resolved Options.MailAutoconfigHost (or ExternalURL.Host).
func SetAutoconfigHost(host string) {
autoconfigHostMu.Lock()
defer autoconfigHostMu.Unlock()
autoconfigHost = strings.TrimSuffix(host, ".")
}
// GetAutoconfigHost returns the configured autoconfig FQDN, with a trailing
// dot. Empty string when no host is configured.
func GetAutoconfigHost() string {
autoconfigHostMu.RLock()
defer autoconfigHostMu.RUnlock()
if autoconfigHost == "" {
return ""
}
return autoconfigHost + "."
}
// EmailAutoConfig publishes the three de-facto mail-client auto-configuration
// standards in one shot:
// - RFC 6186 SRV records for the chosen incoming and outgoing protocols
// - Mozilla Autoconfig: a CNAME at autoconfig.<domain>
// - Microsoft Autodiscover: a CNAME at autodiscover.<domain> and
// _autodiscover._tcp SRV
//
// The struct stores the raw DNS records verbatim. All parsing and
// reconstruction (protocol/host/port from SRV, target normalisation, etc.) is
// done by the frontend editor so that records analyzed from a zone are
// preserved exactly as-is and never re-emitted with subtle differences.
//
// The non-record fields below (DisplayName, IncomingAuth, …) are not
// published in DNS — they configure the HTTP responder that serves the
// Mozilla / Microsoft XML.
type EmailAutoConfig struct {
DisplayName string `json:"displayName,omitempty" happydomain:"label=Provider Display Name,placeholder=Example Mail"`
DisplayShortName string `json:"displayShortName,omitempty" happydomain:"label=Short Name,placeholder=Example"`
IncomingAuth string `json:"incomingAuth,omitempty" happydomain:"label=Incoming Authentication,choices=password-cleartext;password-encrypted;OAuth2;NTLM,default=password-cleartext"`
OutgoingAuth string `json:"outgoingAuth,omitempty" happydomain:"label=Outgoing Authentication,choices=password-cleartext;password-encrypted;OAuth2;NTLM,default=password-cleartext"`
UsernameFormat string `json:"usernameFormat,omitempty" happydomain:"label=Username Format,choices=%EMAILADDRESS%;%EMAILLOCALPART%,default=%EMAILADDRESS%"`
ExchangeServer string `json:"exchangeServer,omitempty" happydomain:"label=Exchange Server (optional),placeholder=mail.example.com,description=Hostname of an on-prem Exchange server. Enables MAPI/EWS in Microsoft Autodiscover responses."`
IncomingSRV *dns.SRV `json:"incomingSRV,omitempty"`
OutgoingSRV *dns.SRV `json:"outgoingSRV,omitempty"`
AutoconfigCNAME *dns.CNAME `json:"autoconfigCNAME,omitempty"`
AutodiscoverCNAME *dns.CNAME `json:"autodiscoverCNAME,omitempty"`
AutodiscoverSRV *dns.SRV `json:"autodiscoverSRV,omitempty"`
}
// srvProtocol pulls the protocol identifier (e.g. "imaps", "submission") out
// of an SRV record's owner name. The owner name may be either a relative
// label like "_imaps._tcp" or a full FQDN like "_imaps._tcp.example.com.".
func srvProtocol(srv *dns.SRV) string {
if srv == nil {
return ""
}
name := strings.TrimPrefix(srv.Hdr.Name, "_")
if i := strings.Index(name, "."); i >= 0 {
name = name[:i]
}
return name
}
// srvHost returns the SRV target with any trailing dot stripped.
func srvHost(srv *dns.SRV) string {
if srv == nil {
return ""
}
return strings.TrimSuffix(srv.Target, ".")
}
// IncomingType returns the incoming protocol identifier derived from the
// stored SRV record, or "" when no incoming SRV is configured.
func (s *EmailAutoConfig) IncomingType() string { return srvProtocol(s.IncomingSRV) }
// IncomingHost returns the incoming hostname (no trailing dot).
func (s *EmailAutoConfig) IncomingHost() string { return srvHost(s.IncomingSRV) }
// IncomingPort returns the incoming port, or 0 when no incoming SRV is set.
func (s *EmailAutoConfig) IncomingPort() uint16 {
if s.IncomingSRV == nil {
return 0
}
return s.IncomingSRV.Port
}
// OutgoingType returns the outgoing protocol identifier derived from the
// stored SRV record.
func (s *EmailAutoConfig) OutgoingType() string { return srvProtocol(s.OutgoingSRV) }
// OutgoingHost returns the outgoing hostname (no trailing dot).
func (s *EmailAutoConfig) OutgoingHost() string { return srvHost(s.OutgoingSRV) }
// OutgoingPort returns the outgoing port, or 0 when no outgoing SRV is set.
func (s *EmailAutoConfig) OutgoingPort() uint16 {
if s.OutgoingSRV == nil {
return 0
}
return s.OutgoingSRV.Port
}
func (s *EmailAutoConfig) GetNbResources() int {
n := 0
if srvConfigured(s.IncomingSRV) {
n++
}
if srvConfigured(s.OutgoingSRV) {
n++
}
if cnameConfigured(s.AutoconfigCNAME) {
n++
}
if cnameConfigured(s.AutodiscoverCNAME) {
n++
}
if srvConfigured(s.AutodiscoverSRV) {
n++
}
return n
}
func (s *EmailAutoConfig) GenComment() string {
var b strings.Builder
if srvConfigured(s.IncomingSRV) {
fmt.Fprintf(&b, "%s %s:%d", strings.ToUpper(s.IncomingType()), s.IncomingHost(), s.IncomingPort())
}
if srvConfigured(s.OutgoingSRV) {
if b.Len() > 0 {
b.WriteString(" + ")
}
fmt.Fprintf(&b, "%s %s:%d", s.OutgoingType(), s.OutgoingHost(), s.OutgoingPort())
}
if cnameConfigured(s.AutoconfigCNAME) || cnameConfigured(s.AutodiscoverCNAME) {
if b.Len() > 0 {
b.WriteString(" + ")
}
b.WriteString("autoconfig/autodiscover")
}
return b.String()
}
// srvConfigured reports whether the SRV pointer was actually filled in by the
// frontend, as opposed to being a zero stub left over from the service-spec
// auto-initializer (which pre-allocates pointer-to-DNS fields with an empty
// Hdr.Name and Target).
func srvConfigured(srv *dns.SRV) bool {
return srv != nil && srv.Hdr.Name != ""
}
func cnameConfigured(c *dns.CNAME) bool {
return c != nil && c.Hdr.Name != ""
}
// GetRecords returns the stored records verbatim. The frontend editor is
// responsible for filling every field — the backend never synthesises or
// rewrites records, so what was analyzed (or what the user typed) is exactly
// what gets published.
func (s *EmailAutoConfig) GetRecords(domain string, ttl uint32, origin string) ([]happydns.Record, error) {
var rrs []happydns.Record
if srvConfigured(s.IncomingSRV) {
rrs = append(rrs, s.IncomingSRV)
}
if srvConfigured(s.OutgoingSRV) {
rrs = append(rrs, s.OutgoingSRV)
}
if cnameConfigured(s.AutoconfigCNAME) {
rrs = append(rrs, s.AutoconfigCNAME)
}
if cnameConfigured(s.AutodiscoverCNAME) {
rrs = append(rrs, s.AutodiscoverCNAME)
}
if srvConfigured(s.AutodiscoverSRV) {
rrs = append(rrs, s.AutodiscoverSRV)
}
return rrs, nil
}
// emailautoconfig_analyze reconstructs an EmailAutoConfig from a zone import.
// It only claims records when both the autoconfig. and autodiscover. CNAMEs
// are present and point to the same target — that's the unambiguous signal
// that this domain was previously published with the high-level service.
// Otherwise it leaves the SRV records for rfc6186_analyze to pick up.
func emailautoconfig_analyze(a *svc.Analyzer) error {
candidates := map[string]*dns.CNAME{}
for _, record := range a.SearchRR(svc.AnalyzerRecordFilter{Type: dns.TypeCNAME, Prefix: "autoconfig."}) {
cname, ok := record.(*dns.CNAME)
if !ok {
continue
}
domain := strings.TrimPrefix(cname.Header().Name, "autoconfig.")
candidates[domain] = cname
}
for domain, autoconfigCNAME := range candidates {
// Find a matching autodiscover. CNAME with the same target.
var autodiscoverCNAME *dns.CNAME
for _, record := range a.SearchRR(svc.AnalyzerRecordFilter{Type: dns.TypeCNAME, Prefix: "autodiscover." + domain}) {
if cname, ok := record.(*dns.CNAME); ok && cname.Header().Name == "autodiscover."+domain && cname.Target == autoconfigCNAME.Target {
autodiscoverCNAME = cname
break
}
}
if autodiscoverCNAME == nil {
continue
}
ec := &EmailAutoConfig{
AutoconfigCNAME: autoconfigCNAME,
AutodiscoverCNAME: autodiscoverCNAME,
}
consumed := []happydns.Record{autoconfigCNAME, autodiscoverCNAME}
// Find at most one incoming + one outgoing SRV under this domain.
for _, p := range []string{"_imaps._tcp.", "_imap._tcp.", "_pop3s._tcp.", "_pop3._tcp."} {
if ec.IncomingSRV != nil {
break
}
for _, record := range a.SearchRR(svc.AnalyzerRecordFilter{Type: dns.TypeSRV, Prefix: p + domain}) {
if srv, ok := record.(*dns.SRV); ok && srv.Header().Name == p+domain {
ec.IncomingSRV = srv
consumed = append(consumed, srv)
break
}
}
}
for _, p := range []string{"_submissions._tcp.", "_submission._tcp."} {
if ec.OutgoingSRV != nil {
break
}
for _, record := range a.SearchRR(svc.AnalyzerRecordFilter{Type: dns.TypeSRV, Prefix: p + domain}) {
if srv, ok := record.(*dns.SRV); ok && srv.Header().Name == p+domain {
ec.OutgoingSRV = srv
consumed = append(consumed, srv)
break
}
}
}
// We need at least one incoming and one outgoing protocol — otherwise
// the analysis isn't strong enough. Leave the records.
if ec.IncomingSRV == nil || ec.OutgoingSRV == nil {
continue
}
// Optionally consume the _autodiscover._tcp SRV.
for _, record := range a.SearchRR(svc.AnalyzerRecordFilter{Type: dns.TypeSRV, Prefix: "_autodiscover._tcp." + domain}) {
if srv, ok := record.(*dns.SRV); ok && srv.Header().Name == "_autodiscover._tcp."+domain {
ec.AutodiscoverSRV = srv
consumed = append(consumed, srv)
break
}
}
for _, rr := range consumed {
if err := a.UseRR(rr, domain, ec); err != nil {
return err
}
}
}
return nil
}
func init() {
svc.RegisterService(
func() happydns.ServiceBody { return &EmailAutoConfig{} },
emailautoconfig_analyze,
happydns.ServiceInfos{
Name: "Email Auto-configuration",
Description: "Publish IMAP/POP/SMTP settings for mail clients via RFC 6186, Mozilla Autoconfig, and Microsoft Autodiscover.",
Family: happydns.SERVICE_FAMILY_ABSTRACT,
Categories: []string{"email"},
RecordTypes: []uint16{dns.TypeSRV, dns.TypeCNAME},
Restrictions: happydns.ServiceRestrictions{
NearAlone: true,
Single: true,
NeedTypes: []uint16{dns.TypeSRV},
},
},
1,
)
}

View file

@ -0,0 +1,138 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package abstract_test
import (
"strings"
"testing"
"github.com/miekg/dns"
"git.happydns.org/happyDomain/services/abstract"
)
func mkSRV(name string, port uint16, target string) *dns.SRV {
return &dns.SRV{
Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeSRV, Class: dns.ClassINET, Ttl: 3600},
Weight: 1,
Port: port,
Target: target,
}
}
func mkCNAME(name, target string) *dns.CNAME {
return &dns.CNAME{
Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 3600},
Target: target,
}
}
func TestEmailAutoConfig_GetRecords(t *testing.T) {
abstract.SetAutoconfigHost("happydomain.example.com")
t.Cleanup(func() { abstract.SetAutoconfigHost("") })
svc := &abstract.EmailAutoConfig{
IncomingSRV: mkSRV("_imaps._tcp", 993, "imap.example.com."),
OutgoingSRV: mkSRV("_submission._tcp", 587, "smtp.example.com."),
AutoconfigCNAME: mkCNAME("autoconfig", "happydomain.example.com."),
AutodiscoverCNAME: mkCNAME("autodiscover", "happydomain.example.com."),
AutodiscoverSRV: mkSRV("_autodiscover._tcp", 443, "happydomain.example.com."),
}
rrs, err := svc.GetRecords("", 3600, "example.com.")
if err != nil {
t.Fatalf("GetRecords: %v", err)
}
want := map[string]bool{
"_imaps._tcp": false,
"_submission._tcp": false,
"autoconfig": false,
"autodiscover": false,
"_autodiscover._tcp": false,
}
for _, rr := range rrs {
name := rr.Header().Name
if _, ok := want[name]; ok {
want[name] = true
} else {
t.Errorf("unexpected record name: %q", name)
}
}
for name, seen := range want {
if !seen {
t.Errorf("missing record %q in output", name)
}
}
if got := svc.GetNbResources(); got != 5 {
t.Errorf("GetNbResources = %d, want 5", got)
}
for _, rr := range rrs {
switch r := rr.(type) {
case *dns.CNAME:
if !strings.HasSuffix(r.Target, "happydomain.example.com.") {
t.Errorf("CNAME target = %q, want suffix happydomain.example.com.", r.Target)
}
case *dns.SRV:
if r.Header().Name == "_autodiscover._tcp" && r.Port != 443 {
t.Errorf("autodiscover SRV port = %d, want 443", r.Port)
}
}
}
}
func TestEmailAutoConfig_GetRecords_NoDiscovery(t *testing.T) {
abstract.SetAutoconfigHost("")
t.Cleanup(func() { abstract.SetAutoconfigHost("") })
svc := &abstract.EmailAutoConfig{
IncomingSRV: mkSRV("_imap._tcp", 143, "imap.example.com."),
OutgoingSRV: mkSRV("_submission._tcp", 587, "smtp.example.com."),
}
rrs, err := svc.GetRecords("", 3600, "example.com.")
if err != nil {
t.Fatalf("GetRecords: %v", err)
}
if len(rrs) != 2 {
t.Errorf("len(rrs) = %d, want 2", len(rrs))
}
}
func TestEmailAutoConfig_GenComment(t *testing.T) {
svc := &abstract.EmailAutoConfig{
IncomingSRV: mkSRV("_imaps._tcp", 993, "imap.example.com."),
OutgoingSRV: mkSRV("_submission._tcp", 587, "smtp.example.com."),
AutoconfigCNAME: mkCNAME("autoconfig", "happydomain.example.com."),
AutodiscoverCNAME: mkCNAME("autodiscover", "happydomain.example.com."),
}
got := svc.GenComment()
for _, want := range []string{"IMAPS", "imap.example.com:993", "submission", "smtp.example.com:587", "autoconfig"} {
if !strings.Contains(got, want) {
t.Errorf("GenComment missing %q in %q", want, got)
}
}
}

View file

@ -187,7 +187,7 @@ func init() {
rfc6186_analyze,
happydns.ServiceInfos{
Name: "E-Mail Services Discovery",
Description: "Make email clients aware of the domain configuration to send and receive emails. RFC 6186",
Description: "Low-level RFC 6186 SRV records. Most users should prefer the higher-level Email Auto-configuration service.",
Family: happydns.SERVICE_FAMILY_ABSTRACT,
Categories: []string{
"email",
@ -200,6 +200,6 @@ func init() {
},
},
},
2,
5,
)
}

View file

@ -311,6 +311,7 @@ func inferOperation(name string) string {
{"ListAll", "list"},
{"List", "list"},
{"Get", "get"},
{"Find", "get"},
{"Count", "count"},
{"Create", "create"},
{"Update", "update"},

View file

@ -45,7 +45,13 @@
let { scope, checksBase, title, domainName, filterAvailability }: Props = $props();
let checkersPromise = $derived(listScopedCheckers(scope));
let checkersPromise = $derived(
listScopedCheckers(scope).then((list) =>
[...list].sort((a, b) =>
(a.name || a.id || "").localeCompare(b.name || b.id || ""),
),
),
);
let metricsApiUrl = $derived(
scope.zoneId && scope.subdomain !== undefined && scope.serviceId
@ -73,20 +79,32 @@
return new Set(statuses.map((s) => s.id).filter((id): id is string => !!id));
}
function sortCheckersByName(
entries: [string, CheckerCheckerDefinition][],
): [string, CheckerCheckerDefinition][] {
return entries.sort(([idA, defA], [idB, defB]) =>
(defA.name || idA).localeCompare(defB.name || idB),
);
}
function getUnconfiguredCheckers(configuredIds: Set<string>): [string, CheckerCheckerDefinition][] {
if (!$checkers) return [];
return Object.entries($checkers).filter(
([id, def]) => !configuredIds.has(id) && def.availability?.[filterAvailability],
return sortCheckersByName(
Object.entries($checkers).filter(
([id, def]) => !configuredIds.has(id) && def.availability?.[filterAvailability],
),
);
}
function getChildrenCheckers(configuredIds: Set<string>): [string, CheckerCheckerDefinition][] {
if (!$checkers) return [];
return Object.entries($checkers).filter(
([id, def]) =>
!configuredIds.has(id) &&
!def.availability?.[filterAvailability] &&
(def.availability?.applyToZone || def.availability?.applyToService),
return sortCheckersByName(
Object.entries($checkers).filter(
([id, def]) =>
!configuredIds.has(id) &&
!def.availability?.[filterAvailability] &&
(def.availability?.applyToZone || def.availability?.applyToService),
),
);
}
</script>

View file

@ -37,7 +37,7 @@
Spinner,
} from "@sveltestrap/sveltestrap";
import { getCheckStatus, getScopedCheckOptions, triggerScopedCheck } from "$lib/api/checkers";
import { getCheckStatus, getScopedCheckOptions, getScopedCheckPlans, triggerScopedCheck } from "$lib/api/checkers";
import type { CheckerScope } from "$lib/api/checkers";
import { collectAllOptionDocs } from "$lib/utils/checkers";
import type {
@ -47,6 +47,7 @@
HappydnsCheckerOptions,
HappydnsCheckerOptionsPositional,
HappydnsCheckerRunRequest,
HappydnsCheckPlan,
} from "$lib/api-base/types.gen";
import Resource from "$lib/components/inputs/Resource.svelte";
import { toasts } from "$lib/stores/toasts";
@ -83,12 +84,14 @@
resolvedStatus = null;
checkStatusPromise = getCheckStatus(name);
scopedOptionsPromise = getScopedCheckOptions(scope, name);
const plansPromise = getScopedCheckPlans(scope, name).catch(() => [] as HappydnsCheckPlan[]);
isOpen = true;
Promise.all([checkStatusPromise, scopedOptionsPromise]).then(
([status, options]: [
Promise.all([checkStatusPromise, scopedOptionsPromise, plansPromise]).then(
([status, options, plans]: [
CheckerCheckerDefinition,
HappydnsCheckerOptionsPositional[],
HappydnsCheckPlan[],
]) => {
resolvedStatus = status;
scopedDefaults = Object.assign({}, ...(options || []).map((p) => p.options || {}));
@ -100,6 +103,21 @@
runOptions[opt.id] = scopedDefaults[opt.id];
}
}
// Pre-select rules according to the existing plan (if any). Without a
// plan, leave activeRules empty so every rule stays active by default.
const rules = status.rules || [];
const planEnabled = plans?.[0]?.enabled;
if (planEnabled && rules.length > 0) {
const next: Record<number, boolean> = {};
for (let i = 0; i < rules.length; i++) {
const rname = rules[i].name;
if (rname && rname in planEnabled) {
next[i] = planEnabled[rname];
}
}
activeRules = next;
}
},
);
}

View file

@ -0,0 +1,478 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import { Alert, FormGroup, Input, Label } from "@sveltestrap/sveltestrap";
import type { Domain } from "$lib/model/domain";
import type { dnsTypeSRV, dnsTypeCNAME } from "$lib/dns_rr";
import BasicInput from "$lib/components/inputs/basic.svelte";
import { t } from "$lib/translations";
interface Props {
dn: string;
origin: Domain;
value: Record<string, any>;
}
let { dn, origin, value = $bindable({}) }: Props = $props();
// ── Parsing helpers (record → form fields) ────────────────────────────
// Pull the protocol identifier out of an SRV owner name like
// "_imaps._tcp" or "_imaps._tcp.example.com.".
function srvProtocol(srv: dnsTypeSRV | undefined | null): string {
if (!srv?.Hdr?.Name) return "";
const n = srv.Hdr.Name.replace(/^_/, "");
const i = n.indexOf(".");
return i >= 0 ? n.slice(0, i) : n;
}
function stripDot(s: string | undefined | null): string {
return (s ?? "").replace(/\.$/, "");
}
function ensureTrailingDot(host: string): string {
if (!host) return "";
return host.endsWith(".") ? host : host + ".";
}
// ── Initial form state, derived from raw records ─────────────────────
// The backend service-spec usecase auto-allocates pointer-to-DNS fields
// with empty stub records (Hdr.Name == "") when serving a freshly-created
// service. Drop those before reading anything else, otherwise the
// unedited form would round-trip a phantom SRV/CNAME back to the zone.
function isStubRecord(r: { Hdr?: { Name?: string } } | null | undefined): boolean {
return r != null && (!r.Hdr || !r.Hdr.Name);
}
if (isStubRecord(value.incomingSRV)) value.incomingSRV = null;
if (isStubRecord(value.outgoingSRV)) value.outgoingSRV = null;
if (isStubRecord(value.autoconfigCNAME)) value.autoconfigCNAME = null;
if (isStubRecord(value.autodiscoverCNAME)) value.autodiscoverCNAME = null;
if (isStubRecord(value.autodiscoverSRV)) value.autodiscoverSRV = null;
const incomingSRV = value.incomingSRV as dnsTypeSRV | undefined;
const outgoingSRV = value.outgoingSRV as dnsTypeSRV | undefined;
let incomingType = $state<string>(srvProtocol(incomingSRV) || "imaps");
let incomingHost = $state<string>(stripDot(incomingSRV?.Target) || "");
let incomingPort = $state<number>(incomingSRV?.Port ?? 993);
let outgoingType = $state<string>(srvProtocol(outgoingSRV) || "submission");
let outgoingHost = $state<string>(stripDot(outgoingSRV?.Target) || "");
let outgoingPort = $state<number>(outgoingSRV?.Port ?? 587);
let publishHTTP = $state<boolean>(
value.autoconfigCNAME != null || value.autodiscoverCNAME != null,
);
if (value.usernameFormat === undefined) value.usernameFormat = "%EMAILADDRESS%";
// Snapshot of initial form state — used to skip rebuilding records when
// the user hasn't actually edited anything (preserves analyzed records
// verbatim).
const initialIncoming = JSON.stringify({
type: srvProtocol(incomingSRV),
host: stripDot(incomingSRV?.Target),
port: incomingSRV?.Port,
});
const initialOutgoing = JSON.stringify({
type: srvProtocol(outgoingSRV),
host: stripDot(outgoingSRV?.Target),
port: outgoingSRV?.Port,
});
const initialPublishHTTP = publishHTTP;
// ── Defaults wiring ──────────────────────────────────────────────────
const incomingDefaults: Record<string, number> = {
imap: 143,
imaps: 993,
pop3: 110,
pop3s: 995,
};
const outgoingDefaults: Record<string, number> = {
submission: 587,
submissions: 465,
};
let prevIncomingType = incomingType;
let prevOutgoingType = outgoingType;
$effect(() => {
if (incomingType !== prevIncomingType) {
const oldDefault = incomingDefaults[prevIncomingType];
if (!incomingPort || incomingPort === oldDefault) {
incomingPort = incomingDefaults[incomingType];
}
prevIncomingType = incomingType;
}
});
$effect(() => {
if (outgoingType !== prevOutgoingType) {
const oldDefault = outgoingDefaults[prevOutgoingType];
if (!outgoingPort || outgoingPort === oldDefault) {
outgoingPort = outgoingDefaults[outgoingType];
}
prevOutgoingType = outgoingType;
}
});
// ── Record reconstruction (form fields → records) ────────────────────
// Preserve the analyzed records' TTL/priority/weight when the user only
// changes display fields. Captured once at script init so the rebuild
// effects don't re-read reactive `value.*` (which would loop).
const baseIncomingTtl = incomingSRV?.Hdr?.Ttl ?? 0;
const baseIncomingPriority = incomingSRV?.Priority ?? 0;
const baseIncomingWeight = incomingSRV?.Weight ?? 1;
const baseOutgoingTtl = outgoingSRV?.Hdr?.Ttl ?? 0;
const baseOutgoingPriority = outgoingSRV?.Priority ?? 0;
const baseOutgoingWeight = outgoingSRV?.Weight ?? 1;
function makeSRV(
name: string,
port: number,
target: string,
ttl: number,
priority: number,
weight: number,
): dnsTypeSRV {
return {
Hdr: { Name: name, Rrtype: 33, Class: 1, Ttl: ttl, Rdlength: 0 },
Priority: priority,
Weight: weight,
Port: port,
Target: target,
};
}
function makeCNAME(name: string, target: string): dnsTypeCNAME {
return {
Hdr: { Name: name, Rrtype: 5, Class: 1, Ttl: 0, Rdlength: 0 },
Target: target,
};
}
// Incoming SRV — only rebuild when user touched the inputs.
$effect(() => {
const cur = JSON.stringify({ type: incomingType, host: incomingHost, port: incomingPort });
if (cur === initialIncoming) return;
value.incomingSRV = incomingHost
? makeSRV(
`_${incomingType}._tcp`,
incomingPort,
ensureTrailingDot(incomingHost),
baseIncomingTtl,
baseIncomingPriority,
baseIncomingWeight,
)
: null;
});
$effect(() => {
const cur = JSON.stringify({ type: outgoingType, host: outgoingHost, port: outgoingPort });
if (cur === initialOutgoing) return;
value.outgoingSRV = outgoingHost
? makeSRV(
`_${outgoingType}._tcp`,
outgoingPort,
ensureTrailingDot(outgoingHost),
baseOutgoingTtl,
baseOutgoingPriority,
baseOutgoingWeight,
)
: null;
});
// HTTP discovery — toggles add/remove the three records. The CNAME/SRV
// targets point at the happyDomain instance currently serving this UI,
// which is also the host that answers Mozilla Autoconfig and Microsoft
// Autodiscover XML.
const discoveryTarget = ensureTrailingDot(
typeof window !== "undefined" ? window.location.hostname : "",
);
$effect(() => {
if (publishHTTP === initialPublishHTTP) return;
if (publishHTTP) {
value.autoconfigCNAME = makeCNAME("autoconfig", discoveryTarget);
value.autodiscoverCNAME = makeCNAME("autodiscover", discoveryTarget);
value.autodiscoverSRV = makeSRV(
"_autodiscover._tcp",
443,
discoveryTarget,
0,
0,
0,
);
} else {
value.autoconfigCNAME = null;
value.autodiscoverCNAME = null;
value.autodiscoverSRV = null;
}
});
// ── UI metadata ──────────────────────────────────────────────────────
const authChoices = [
{ value: "password-cleartext", label: "Password (cleartext, over TLS)" },
{ value: "password-encrypted", label: "Password (encrypted)" },
{ value: "OAuth2", label: "OAuth2" },
{ value: "NTLM", label: "NTLM" },
];
const incomingProtocols = [
{ value: "imaps", label: "IMAPS (port 993, TLS)" },
{ value: "imap", label: "IMAP (port 143, STARTTLS or plain)" },
{ value: "pop3s", label: "POP3S (port 995, TLS)" },
{ value: "pop3", label: "POP3 (port 110, STARTTLS or plain)" },
];
const outgoingProtocols = [
{ value: "submission", label: "Submission (port 587, STARTTLS)" },
{ value: "submissions", label: "Submissions (port 465, TLS)" },
];
const usernameFormats = [
{ value: "%EMAILADDRESS%", label: "Full email address (user@example.com)" },
{ value: "%EMAILLOCALPART%", label: "Local part only (user)" },
];
let portWarning = $derived.by(() => {
const issues: string[] = [];
if (incomingPort && (incomingPort < 1 || incomingPort > 65535))
issues.push("Incoming port must be between 1 and 65535");
if (outgoingPort && (outgoingPort < 1 || outgoingPort > 65535))
issues.push("Outgoing port must be between 1 and 65535");
const inDef = incomingDefaults[incomingType];
const outDef = outgoingDefaults[outgoingType];
if (incomingPort && inDef && incomingPort !== inDef)
issues.push(`Non-standard port ${incomingPort} for ${incomingType}`);
if (outgoingPort && outDef && outgoingPort !== outDef)
issues.push(`Non-standard port ${outgoingPort} for ${outgoingType}`);
return issues;
});
</script>
<div>
<h4 class="text-primary pb-1 border-bottom border-1">
{$t("services.email-autoconfig.title", { default: "Email Auto-configuration" })}
</h4>
<p class="text-muted small">
{$t("services.email-autoconfig.intro", {
default:
"Publishes IMAP/POP/SMTP settings via RFC 6186 SRV records, Mozilla Autoconfig, and Microsoft Autodiscover so mail clients can configure themselves automatically.",
})}
</p>
{#if portWarning.length > 0}
<Alert color="warning" class="py-2 small mb-3">
{#each portWarning as w}
<div>{w}</div>
{/each}
</Alert>
{/if}
<h5 class="mt-3 text-primary pb-1 border-bottom border-1">
{$t("services.email-autoconfig.incoming", { default: "Incoming server" })}
</h5>
<FormGroup row>
<Label md="4" class="text-md-end text-primary">{$t("services.email-autoconfig.protocol", { default: "Protocol" })}</Label>
<div class="col-md-8">
<Input type="select" bind:value={incomingType} bsSize="sm">
{#each incomingProtocols as p}
<option value={p.value}>{p.label}</option>
{/each}
</Input>
</div>
</FormGroup>
<BasicInput
edit
index="incomingHost"
specs={{
id: "incomingHost",
label: $t("services.email-autoconfig.hostname", { default: "Hostname" }),
placeholder: "imap.example.com",
type: "string",
required: true,
description: $t("services.email-autoconfig.incoming-hostname-desc", {
default: "FQDN of your IMAP/POP3 server.",
}),
}}
bind:value={incomingHost}
/>
<BasicInput
edit
index="incomingPort"
specs={{
id: "incomingPort",
label: $t("services.email-autoconfig.port", { default: "Port" }),
placeholder: "993",
type: "uint16",
required: true,
}}
bind:value={incomingPort}
/>
<FormGroup row>
<Label md="4" class="text-md-end text-primary">{$t("services.email-autoconfig.auth", { default: "Authentication" })}</Label>
<div class="col-md-8">
<Input type="select" bind:value={value.incomingAuth} bsSize="sm">
{#each authChoices as a}
<option value={a.value}>{a.label}</option>
{/each}
</Input>
</div>
</FormGroup>
<h5 class="mt-3 text-primary pb-1 border-bottom border-1">
{$t("services.email-autoconfig.outgoing", { default: "Outgoing server" })}
</h5>
<FormGroup row>
<Label md="4" class="text-md-end text-primary">{$t("services.email-autoconfig.protocol", { default: "Protocol" })}</Label>
<div class="col-md-8">
<Input type="select" bind:value={outgoingType} bsSize="sm">
{#each outgoingProtocols as p}
<option value={p.value}>{p.label}</option>
{/each}
</Input>
</div>
</FormGroup>
<BasicInput
edit
index="outgoingHost"
specs={{
id: "outgoingHost",
label: $t("services.email-autoconfig.hostname", { default: "Hostname" }),
placeholder: "smtp.example.com",
type: "string",
required: true,
description: $t("services.email-autoconfig.outgoing-hostname-desc", {
default: "FQDN of your SMTP submission server.",
}),
}}
bind:value={outgoingHost}
/>
<BasicInput
edit
index="outgoingPort"
specs={{
id: "outgoingPort",
label: $t("services.email-autoconfig.port", { default: "Port" }),
placeholder: "587",
type: "uint16",
required: true,
}}
bind:value={outgoingPort}
/>
<FormGroup row>
<Label md="4" class="text-md-end text-primary">{$t("services.email-autoconfig.auth", { default: "Authentication" })}</Label>
<div class="col-md-8">
<Input type="select" bind:value={value.outgoingAuth} bsSize="sm">
{#each authChoices as a}
<option value={a.value}>{a.label}</option>
{/each}
</Input>
</div>
</FormGroup>
<h5 class="mt-3 text-primary pb-1 border-bottom border-1">
{$t("services.email-autoconfig.discovery", { default: "Discovery" })}
</h5>
<FormGroup>
<Input
type="checkbox"
label={$t("services.email-autoconfig.publish-http", {
default: "Publish HTTP discovery (autoconfig./autodiscover. CNAMEs)",
})}
bind:checked={publishHTTP}
/>
<p class="small text-muted mt-1 mb-0">
{$t("services.email-autoconfig.publish-http-desc", {
default:
"When enabled, happyDomain creates CNAMEs for autoconfig.<your-domain> and autodiscover.<your-domain> pointing to this happyDomain instance, which serves the corresponding XML over HTTPS so Thunderbird and Outlook can self-configure.",
})}
</p>
</FormGroup>
<BasicInput
edit
index="exchangeServer"
specs={{
id: "exchangeServer",
label: $t("services.email-autoconfig.exchange", { default: "Exchange Server (optional)" }),
placeholder: "mail.example.com",
type: "string",
description: $t("services.email-autoconfig.exchange-desc", {
default:
"Hostname of an on-premises Microsoft Exchange server. Enables MAPI/EWS in the Autodiscover response.",
}),
}}
bind:value={value.exchangeServer}
/>
<h5 class="mt-3 text-primary pb-1 border-bottom border-1">
{$t("services.email-autoconfig.branding", { default: "Branding" })}
</h5>
<BasicInput
edit
index="displayName"
specs={{
id: "displayName",
label: $t("services.email-autoconfig.display-name", { default: "Provider Display Name" }),
placeholder: "Example Mail",
type: "string",
}}
bind:value={value.displayName}
/>
<BasicInput
edit
index="displayShortName"
specs={{
id: "displayShortName",
label: $t("services.email-autoconfig.display-short-name", { default: "Short Name" }),
placeholder: "Example",
type: "string",
}}
bind:value={value.displayShortName}
/>
<FormGroup row>
<Label md="4" class="text-md-end text-primary">{$t("services.email-autoconfig.username-format", { default: "Username Format" })}</Label>
<div class="col-md-8">
<Input type="select" bind:value={value.usernameFormat} bsSize="sm">
{#each usernameFormats as f}
<option value={f.value}>{f.label}</option>
{/each}
</Input>
</div>
</FormGroup>
</div>

View file

@ -0,0 +1,127 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2025 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import { Button, Icon, Input } from "@sveltestrap/sveltestrap";
import type { Domain } from "$lib/model/domain";
import type { dnsResource, dnsTypeTXT } from "$lib/dns_rr";
import { getRrtype, newRR } from "$lib/dns_rr";
import { t } from "$lib/translations";
interface Props {
dn: string;
origin: Domain;
value: dnsResource;
}
let { dn, origin, value = $bindable({}) }: Props = $props();
const type = "svcs.DMARCReport";
const SUFFIX = "._report._dmarc";
if (!Array.isArray(value["txt"])) value["txt"] = [];
function getDomain(rr: dnsTypeTXT): string {
const n = rr.Hdr?.Name ?? "";
return n.endsWith(SUFFIX) ? n.slice(0, -SUFFIX.length) : n;
}
function setDomain(idx: number, d: string) {
const rrs = value["txt"] as dnsTypeTXT[];
rrs[idx].Hdr.Name = (d || "") + SUFFIX;
}
function addDomain() {
const rec = newRR("" + SUFFIX, getRrtype("TXT")) as dnsTypeTXT;
rec.Txt = "v=DMARC1";
(value["txt"] as dnsTypeTXT[]).push(rec);
}
function removeDomain(idx: number) {
(value["txt"] as dnsTypeTXT[]).splice(idx, 1);
}
</script>
<div>
<h4 class="text-primary pb-1 border-bottom border-1">
Domains allowed to send DMARC reports here
</h4>
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Domain</th>
<th></th>
</tr>
</thead>
<tbody>
{#if (value["txt"] as dnsTypeTXT[]).length}
{#each value["txt"] as dnsTypeTXT[] as rr, idx}
<tr>
<td>
<Input
bsSize="sm"
value={getDomain(rr)}
oninput={(e) =>
setDomain(idx, (e.target as HTMLInputElement).value)}
/>
</td>
<td>
<Button
type="button"
color="danger"
outline
size="sm"
onclick={() => removeDomain(idx)}
>
<Icon name="trash" />
</Button>
</td>
</tr>
{/each}
{:else}
<tr>
<td colspan={2} class="fst-italic text-center">
{$t("common.no-content")}
</td>
</tr>
{/if}
</tbody>
<tfoot>
<tr>
<td colspan={2}>
<Button
type="button"
color="primary"
outline
size="sm"
onclick={addDomain}
>
<Icon name="plus" />
{$t("common.new-row")}
</Button>
</td>
</tr>
</tfoot>
</table>
</div>

View file

@ -415,6 +415,29 @@
"propagation-in-progress": "DNS propagation in progress",
"propagation-remaining": "Propagation complete in {{countdown}}"
},
"services": {
"email-autoconfig": {
"title": "Email Auto-configuration",
"intro": "Publishes IMAP/POP/SMTP settings via RFC 6186 SRV records, Mozilla Autoconfig, and Microsoft Autodiscover so mail clients can configure themselves automatically.",
"incoming": "Incoming server",
"outgoing": "Outgoing server",
"discovery": "Discovery",
"branding": "Branding",
"protocol": "Protocol",
"hostname": "Hostname",
"port": "Port",
"auth": "Authentication",
"incoming-hostname-desc": "FQDN of your IMAP/POP3 server.",
"outgoing-hostname-desc": "FQDN of your SMTP submission server.",
"publish-http": "Publish HTTP discovery (autoconfig./autodiscover. CNAMEs)",
"publish-http-desc": "When enabled, happyDomain creates CNAMEs for autoconfig.<your-domain> and autodiscover.<your-domain> pointing to this happyDomain instance, which serves the corresponding XML over HTTPS so Thunderbird and Outlook can self-configure.",
"exchange": "Exchange Server (optional)",
"exchange-desc": "Hostname of an on-premises Microsoft Exchange server. Enables MAPI/EWS in the Autodiscover response.",
"display-name": "Provider Display Name",
"display-short-name": "Short Name",
"username-format": "Username Format"
}
},
"records": {
"add": "Add record",
"class": "Class",

View file

@ -414,6 +414,29 @@
"propagation-in-progress": "Propagation DNS en cours",
"propagation-remaining": "Propagation complète dans {{countdown}}"
},
"services": {
"email-autoconfig": {
"title": "Auto-configuration des clients de messagerie",
"intro": "Publie les paramètres IMAP/POP/SMTP via les enregistrements SRV (RFC 6186), Mozilla Autoconfig et Microsoft Autodiscover, afin que les clients mail puissent se configurer automatiquement.",
"incoming": "Serveur entrant",
"outgoing": "Serveur sortant",
"discovery": "Découverte",
"branding": "Identité visuelle",
"protocol": "Protocole",
"hostname": "Nom d'hôte",
"port": "Port",
"auth": "Authentification",
"incoming-hostname-desc": "Nom de domaine pleinement qualifié de votre serveur IMAP/POP3.",
"outgoing-hostname-desc": "Nom de domaine pleinement qualifié de votre serveur SMTP de soumission.",
"publish-http": "Publier la découverte HTTP (CNAME autoconfig./autodiscover.)",
"publish-http-desc": "Une fois activé, happyDomain crée des enregistrements CNAME pour autoconfig.<votre-domaine> et autodiscover.<votre-domaine> pointant vers cette instance d'happyDomain, qui sert le XML correspondant en HTTPS afin que Thunderbird et Outlook se configurent automatiquement.",
"exchange": "Serveur Exchange (optionnel)",
"exchange-desc": "Nom d'hôte d'un serveur Microsoft Exchange sur site. Active MAPI/EWS dans la réponse Autodiscover.",
"display-name": "Nom d'affichage du fournisseur",
"display-short-name": "Nom court",
"username-format": "Format du nom d'utilisateur"
}
},
"upgrade": {
"title": "Une mise à jour est disponible!",
"content": "Une nouvelle version d'happyDomain est disponible. Pour l'activer, vous pouvez cliquer ici pour rafraîchir la page."

View file

@ -16,6 +16,44 @@ export const servicesSpecs: Record<string, ServiceInfos> = {
"nearAlone": true
}
},
"abstract.CalDAV": {
"name": "CalDAV (Calendar)",
"_svctype": "abstract.CalDAV",
"description": "Announce a CalDAV calendar server for the domain via SRV records (RFC 6764).",
"family": "abstract",
"categories": [
"service",
"groupware"
],
"record_types": null,
"restrictions": {
"nearAlone": true,
"needTypes": [
33,
16
],
"single": true
}
},
"abstract.CardDAV": {
"name": "CardDAV (Address Book)",
"_svctype": "abstract.CardDAV",
"description": "Announce a CardDAV address book server for the domain via SRV records (RFC 6764).",
"family": "abstract",
"categories": [
"service",
"groupware"
],
"record_types": null,
"restrictions": {
"nearAlone": true,
"needTypes": [
33,
16
],
"single": true
}
},
"abstract.Delegation": {
"name": "Delegation",
"_svctype": "abstract.Delegation",
@ -41,6 +79,26 @@ export const servicesSpecs: Record<string, ServiceInfos> = {
"single": true
}
},
"abstract.EmailAutoConfig": {
"name": "Email Auto-configuration",
"_svctype": "abstract.EmailAutoConfig",
"description": "Publish IMAP/POP/SMTP settings for mail clients via RFC 6186, Mozilla Autoconfig, and Microsoft Autodiscover.",
"family": "abstract",
"categories": [
"email"
],
"record_types": [
33,
5
],
"restrictions": {
"nearAlone": true,
"needTypes": [
33
],
"single": true
}
},
"abstract.GithubOrgVerif": {
"name": "GitHub Verification",
"_svctype": "abstract.GithubOrgVerif",
@ -109,6 +167,34 @@ export const servicesSpecs: Record<string, ServiceInfos> = {
"nearAlone": true
}
},
"abstract.LDAP": {
"name": "LDAP Directory",
"_svctype": "abstract.LDAP",
"description": "Expose an LDAP directory under your domain.",
"family": "abstract",
"categories": [
"service"
],
"record_types": null,
"restrictions": {
"nearAlone": true,
"needTypes": [
33
],
"single": true
}
},
"abstract.LibravatarServer": {
"name": "Federated Avatar",
"_svctype": "abstract.LibravatarServer",
"description": "Declare a libravatar server for this subdomain.",
"family": "abstract",
"categories": [
"service"
],
"record_types": null,
"restrictions": {}
},
"abstract.MatrixIM": {
"name": "Matrix IM",
"_svctype": "abstract.MatrixIM",
@ -186,7 +272,7 @@ export const servicesSpecs: Record<string, ServiceInfos> = {
"abstract.RFC6186": {
"name": "E-Mail Services Discovery",
"_svctype": "abstract.RFC6186",
"description": "Make email clients aware of the domain configuration to send and receive emails. RFC 6186",
"description": "Low-level RFC 6186 SRV records. Most users should prefer the higher-level Email Auto-configuration service.",
"family": "abstract",
"categories": [
"email"
@ -413,6 +499,24 @@ export const servicesSpecs: Record<string, ServiceInfos> = {
]
}
},
"svcs.DMARCReport": {
"name": "DMARC allow receiving reports",
"_svctype": "svcs.DMARCReport",
"description": "Allow a domain to receive DMARC reports for another domain.",
"family": "",
"categories": [
"email"
],
"record_types": [
16
],
"restrictions": {
"nearAlone": true,
"needTypes": [
16
]
}
},
"svcs.MTA_STS": {
"name": "MTA-STS",
"_svctype": "svcs.MTA_STS",

View file

@ -38,7 +38,7 @@
let { service, class: className = "" }: Props = $props();
</script>
{#if service && $userSession.settings && $servicesSpecsLoaded}
{#if service && $userSession.settings && $servicesSpecsLoaded && $servicesSpecs[service._svctype]}
{#if $servicesSpecs[service._svctype].categories?.length && !$userSession.settings.showrrtypes}
<div class="d-flex align-items-center gap-1 {className}">
{#each $servicesSpecs[service._svctype].categories as category}

View file

@ -76,11 +76,11 @@
<Spinner color="primary" />
</div>
{:else}
<CardBody title={service ? $servicesSpecs[service._svctype].name : undefined}>
<CardBody title={service && $servicesSpecs[service._svctype] ? $servicesSpecs[service._svctype].name : undefined}>
<div class="d-flex justify-content-between gap-1 mb-2">
<CardTitle class="text-truncate mb-0">
{#if service}
{$servicesSpecs[service._svctype].name}
{$servicesSpecs[service._svctype]?.name ?? service._svctype}
{:else}
<Icon name="plus-circle" /> {$t("service.new")}
{/if}
@ -110,7 +110,7 @@
</div>
<CardSubtitle class="mb-2 text-muted fst-italic">
{#if service}
{$servicesSpecs[service._svctype].description}
{$servicesSpecs[service._svctype]?.description ?? ""}
{:else}
{$t("service.new-description")}
{/if}