Compare commits
19 commits
88b5d1cc52
...
977945c72f
| Author | SHA1 | Date | |
|---|---|---|---|
| 977945c72f | |||
| f606e414db | |||
| 3d0d6db33f | |||
| 23c9a00600 | |||
| 513a646394 | |||
| f6ccba3958 | |||
| 9d3dabc32a | |||
| 4d81514f4d | |||
| b424ac1234 | |||
| a9304193c0 | |||
| 5d06cd91ff | |||
| 4a8ac74ffb | |||
| 7366281c53 | |||
| 0a635ee9f5 | |||
| ccb79b081d | |||
| 9931c13543 | |||
| 8e36050683 | |||
| 395ea0e292 | |||
| 74a7aff190 |
35 changed files with 2492 additions and 40 deletions
|
|
@ -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
|
||||
|
|
|
|||
18
Dockerfile
18
Dockerfile
|
|
@ -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
168
docs/email-autoconfig.md
Normal 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
6
go.mod
|
|
@ -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
10
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
185
internal/api/controller/email_autoconfig.go
Normal file
185
internal/api/controller/email_autoconfig.go
Normal 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)
|
||||
}
|
||||
78
internal/api/route/email_autoconfig.go
Normal file
78
internal/api/route/email_autoconfig.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
167
internal/usecase/emailautoconfig/autodiscover_xml.go
Normal file
167
internal/usecase/emailautoconfig/autodiscover_xml.go
Normal 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
|
||||
}
|
||||
155
internal/usecase/emailautoconfig/mozilla_xml.go
Normal file
155
internal/usecase/emailautoconfig/mozilla_xml.go
Normal 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
|
||||
}
|
||||
144
internal/usecase/emailautoconfig/usecase.go
Normal file
144
internal/usecase/emailautoconfig/usecase.go
Normal 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)
|
||||
}
|
||||
120
internal/usecase/emailautoconfig/xml_test.go
Normal file
120
internal/usecase/emailautoconfig/xml_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
45
model/email_autoconfig.go
Normal 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)
|
||||
}
|
||||
330
services/abstract/email_autoconfig.go
Normal file
330
services/abstract/email_autoconfig.go
Normal 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,
|
||||
)
|
||||
}
|
||||
138
services/abstract/email_autoconfig_test.go
Normal file
138
services/abstract/email_autoconfig_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -311,6 +311,7 @@ func inferOperation(name string) string {
|
|||
{"ListAll", "list"},
|
||||
{"List", "list"},
|
||||
{"Get", "get"},
|
||||
{"Find", "get"},
|
||||
{"Count", "count"},
|
||||
{"Create", "create"},
|
||||
{"Update", "update"},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
127
web/src/lib/components/services/editors/svcs.DMARCReport.svelte
Normal file
127
web/src/lib/components/services/editors/svcs.DMARCReport.svelte
Normal 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>
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue