Files
Lucas RayanandClaude Sonnet 5.5 6e49ca00e2 feat(bimi): reconcile the BIMI record to Cloudflare and keep it in step with the logo
The reconciler never wrote the BIMI record: only the console's verification
knew about it, so a stored logo was never published to the zone.

- SyncDnsRecordsInput carries the BIMI logo and certificate URLs; the record is
  created, updated (a= added or dropped) and, when the logo is removed,
  deleted. Only a record carrying this reconciler's comment is retired: one
  made by hand is left alone
- bimi.Publish decides the record in one place for the timer sweep, the
  console's button and the panel's verification. A held certificate is
  advertised only while it is valid for the logo being served: replacing the
  logo ends the certificate, and a record that kept naming it makes every
  receiver reject BIMI, including those that need no certificate
- saving or removing the logo or the certificate triggers the reconciliation in
  the background; the panel shows the record as the server writes it, says when
  a certificate is held back and why, and has a publish-now button
- a read error is not 'no logo': the sync leaves the record alone instead of
  deleting it over a database hiccup

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:17:07 -03:00

96 lines
3.5 KiB
Go

package main
import (
"context"
"fmt"
"log"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/infrastructure/cloudflare"
"lambdamail/protocols/internal/infrastructure/netdns"
"lambdamail/protocols/internal/infrastructure/postgres"
httppresentation "lambdamail/protocols/internal/presentation/http"
)
// domainReconciler publishes one domain's expected records on demand.
//
// The console could tell an operator a record was missing and offer no way to
// create it: the button reached an endpoint that wrote an audit row, and the
// only code able to talk to the DNS provider ran on a timer, for one domain.
type domainReconciler struct {
run func(ctx context.Context, domain string) (*usecase.SyncDnsRecordsOutput, error)
}
func (d domainReconciler) ReconcileDomain(
ctx context.Context, domain string,
) (httppresentation.ReconcileResult, error) {
out, err := d.run(ctx, domain)
if err != nil {
return httppresentation.ReconcileResult{}, err
}
conflicts := make([]string, 0, len(out.Conflicts))
for _, c := range out.Conflicts {
// Named plainly, because the operator has to decide what to do: the
// existing value may be something they still need.
conflicts = append(conflicts, fmt.Sprintf("%s %s already holds %q", c.Type, c.Name, c.Existing))
}
return httppresentation.ReconcileResult{
Domain: domain,
Created: out.CreatedCount,
Updated: out.UpdatedCount,
Unchanged: out.UnchangedCount,
Deleted: out.DeletedCount,
Conflicts: conflicts,
Errors: out.Errors,
}, nil
}
// reconcileDomainOnDemand publishes one domain's records, now.
//
// It builds the same use case the timer uses rather than sharing an instance:
// the console's button and the background sweep must not be able to interleave
// halfway through each other's zone updates.
func reconcileDomainOnDemand(
ctx context.Context, cfg config, aliasRepo port.SystemAliasRepository,
dkimRepo *postgres.DkimRepository, bimiRepo *postgres.BimiRepository, domain string,
) (*usecase.SyncDnsRecordsOutput, error) {
syncUC := usecase.NewSyncDnsRecordsUseCase(cloudflare.NewCloudflareAdapter(cfg.CloudflareToken), aliasRepo)
syncUC.SetVerifier(netdns.NewPublicVerifier())
input := usecase.SyncDnsRecordsInput{
DomainName: domain,
MailHost: cfg.PrimaryMailHost,
ServerIPv4: cfg.PublicIPv4,
ServerIPv6: cfg.PublicIPv6,
DaneEnabled: cfg.OutboundDane,
RelaySpfInclude: relayConfig(cfg).SpfInclude(),
}
publication := bimiPublication(ctx, bimiRepo, cfg, domain)
input.BimiLogoURL, input.BimiVmcURL = publication.LogoURL, publication.CertificateURL
// A domain with no DKIM key of its own would publish a selector pointing
// at nothing, so the keys are provisioned first, exactly as the timer does.
if dkimRepo != nil {
keys, err := usecase.NewProvisionDkimKeysUseCase(dkimRepo, generateDkimKey).Execute(ctx, domain)
if err != nil {
return nil, fmt.Errorf("provision DKIM keys for %s: %w", domain, err)
}
input.RsaDkimPubKey = keys.RsaPublicKey
input.EdDkimPubKey = keys.Ed25519PublicKey
if len(keys.Created) > 0 {
log.Printf("DNS reconcile (on demand): generated DKIM keys for %s: %v", domain, keys.Created)
}
}
out, err := syncUC.Execute(ctx, input)
if err != nil {
return nil, err
}
log.Printf("DNS reconcile (on demand) for %s: %s (created=%d updated=%d unchanged=%d conflicts=%d)",
domain, out.Status, out.CreatedCount, out.UpdatedCount, out.UnchangedCount, out.ConflictCount)
return out, nil
}