🌐 Domain DNS Records — Complete Lifecycle

What is Created at Init · Updated at Set Complete · Checked nightly — for every column, every sending mechanism, every setup type.

📑 Table of Contents
  1. Core Principle
  2. DomainRecords Entity
  3. Three Phases
  4. Legend
  5. GSuite
  6. Outlook
  7. Postal
  8. Hmail
  9. Nightly Check Detail
  10. Email Notifications
  11. Master Summary Matrix

1. Core Principle

Within a given sending mechanism, almost all DNS records are the SAME for every domain. The only truly dynamic/unique records are:

Sending MechanismAlways Same (→ Init)Dynamic / Unique (→ Set Complete)
GSuiteMX, SPF, DMARC, CNAME, DKIMSelectorDKIM (unique RSA key per domain)
OutlookMX, SPF, DMARC, CNAME, CNAME5, DKIMSelectorCNAME3, CNAME4 (DKIM selectors from Graph API)
PostalMX, SPF, DMARC, CNAME, CNAME2, DKIM, DKIMSelectorNothing — all known upfront from PostalDomains config
HmailMX, DMARC, CNAME, DKIMSelectorSPF (from Logins.Parameters2), DKIM (RSA key)

💡 Key insight from live database analysis: Every GSuite domain has identical MX, SPF, CNAME, DMARC. The only varying field is DKIM. For Outlook, MX is domain-specific but predictable — we know it at Init ({d}.mail.protection.outlook.com). For Postal, even DKIM is known upfront from the PostalDomains table.

2. DomainRecords Entity — All Columns

#ColumnTypeDescriptionPopulated When
1Idint (PK)Auto-increment identityAuto
2DomainNamestringFull domain name, e.g. outreach.client.comInit
3DomainTypestring(50)"Domain" or "Subdomain"Init
4SendingMechanismstring(50)GSuite · Outlook · Postal · Hmail · OtherInit
5CNAMEstring(500)Tracking domain target. Resolved CNAME target from BrandSSL Logins, e.g. track.sopro.io. Same for all mechanisms.Init (from BrandSSL Logins.Parameters2)
6CNAME2string(500)Postal return-path. psrp.{d}. Postal only. Empty for all others.Init (Postal only)
7CNAME3string(500)DKIM selector 1 CNAME. Outlook only. Deferred to SetComplete — resolved from Graph API or live DNS.SetComplete (Outlook only: DKIM CNAME target)
8CNAME4string(500)DKIM selector 2 CNAME. Outlook only. Deferred to SetComplete — resolved from Graph API or live DNS.SetComplete (Outlook only: DKIM CNAME target)
9CNAME5string(500)Autodiscover. autodiscover.{d}autodiscover.outlook.com. Outlook only. Empty for all others.Init (standard: autodiscover.outlook.com)
10MXstring(500)MX records — pipe-delimitedInit (standard per mechanism, including Outlook)
11SPFstring(500)SPF TXT recordInit (GSuite/Outlook/Postal: standard) · SetComplete (Hmail: from Logins)
12DKIMstring(500)DKIM TXT value or RSA public keyInit (Postal: from PostalDomains) · SetComplete (GSuite/Hmail: unique per domain)
13DKIMSelectorstring(150)DKIM selector prefixInit (computed from mechanism)
14DMARCstring(500)DMARC TXT: v=DMARC1; p=quarantine;Init (standard for all)
15LastCheckedDateTime?Timestamp of last DNS checkInitNightly
16DomainStatusstring(50)"OK" or "ERROR"Nightly
17ErrorsstringConcatenated error messagesNightly
18CompletedboolWhether domain setup is marked completeSetComplete
19DeletedboolSoft-delete flagDefault false

3. Three Phases: Init → Set Complete → Nightly

flowchart LR A["🟢 INIT
InsertDomainRecordsSkeleton"] --> B["🟡 SET COMPLETE
_AddDomainsToDomainRecords
(HangFire)"] B --> C["🔵 NIGHTLY CHECK
_CheckDomainRecords
Completed=0: standard only
Completed=1: full check"] A -.- A1["Populate ALL known records:
MX, SPF, DMARC, CNAME, CNAME5,
DKIMSelector, Postal DKIM.
Only deferred: GSuite DKIM,
Outlook CNAME3/4, Hmail SPF+DKIM."] B -.- B1["Resolve dynamic-only:
GSuite DKIM (CheckDKIMRecord),
Outlook CNAME3/4 (Graph API),
Hmail SPF+DKIM.
Set Completed=true."] C -.- C1["Non-deleted domains only
(Deleted=false).
Completed=0: verify MX, SPF,
DMARC, CNAME, CNAME5.
Completed=1: also verify DKIM.
Parallel checks (Task.WhenAll)."] style A fill:#e6f4ea,stroke:#188038,color:#188038 style B fill:#fef7e0,stroke:#e37400,color:#e37400 style C fill:#e8f0fe,stroke:#1a73e8,color:#1a73e8

🟢 Phase 1 — INIT

Trigger: "Init" button → InitHangFireDomainInsertDomainRecordsSkeleton

Populates ALL predictable records immediately:

DomainName, DomainType, SendingMechanism, CNAME1-5 hostnames, DKIMSelector, LastChecked

Standard records (always same per mechanism): MX (all), SPF (GSuite/Outlook/Postal), DMARC (all), CNAME5 (Outlook: autodiscover.outlook.com)

Postal DKIM also at Init: RSA key from PostalDomains table — known upfront

Deferred to Set Complete: GSuite DKIM, Outlook CNAME3/4 (DKIM selectors), Hmail SPF+DKIM

Completed = false

🟡 Phase 2 — SET COMPLETE

Trigger: "Set Completed" button → StupidMethod → enqueues _AddDomainsToDomainRecords (HangFire)

Only resolves dynamic/unique records:

GSuite: DKIM via CheckDKIMRecord live DNS lookup

Outlook: CNAME3/4 DKIM selectors from Graph API

Hmail: SPF from Logins.Parameters2, DKIM RSA key

Does NOT overwrite standard Init values — loads existing record, only updates resolved fields

Completed = true

🔵 Phase 3 — NIGHTLY CHECK

Trigger: HangFire recurring → _CheckDomainRecords

Scope: Only non-deleted domains (Deleted == false). Soft-deleted domains are skipped entirely.

Two-tier check based on Completed flag:

Completed = 0: Verify MX (if non-empty), SPF (if non-empty), DMARC, CNAME, CNAME5 — the standard records set at Init

Completed = 1: Full check — all of above + DKIM

Verify-only — never creates. Parallel via Task.WhenAll.

4. Legend — Tags & Colour Coding

CREATE First populated at Init
UPDATE Resolved at Set Complete
CHECK Verified nightly against live DNS
EMPTY Left blank (deferred or N/A)
N/A Not applicable

5. GSuite — Per Setup Type

flowchart TD G1["GSuite · GoDaddy-owned"] --> G1a["Init: ALL standard records
MX: 5× Google ASPMX (standard)
SPF: include:_spf.google.com ~all (standard)
DMARC: v=DMARC1; p=quarantine (standard)
CNAME: track.sopro.io
DKIMSelector: google._domainkey
DKIM: EMPTY (deferred)"] G1a --> G1b["SetComplete: DKIM only (dynamic)
CheckDKIMRecord live DNS lookup"] G1b --> G1c["Nightly Completed=0:
CheckMX, CheckSPF, CheckDMARC, CheckCNAME
Nightly Completed=1:
+ CheckDKIM"] G2["GSuite · DNSRecord (SetupType=2)"] --> G2a["Same Init values. DKIM from
GSuite Admin SDK at SetComplete.
DNS HTML emailed to Ops Owner."] G3["GSuite · SMTPDetails (SetupType=3)"] --> G3a["Tracking CNAME only.
MX/SPF/DKIM/DMARC all empty.
domain.Type = null."] style G1 fill:#e8f0fe,stroke:#1a73e8 style G2 fill:#e8f0fe,stroke:#1a73e8 style G3 fill:#e8f0fe,stroke:#1a73e8

5.1 GoDaddy-owned

ColumnInitSet CompleteNightly (C=0)Nightly (C=1)
CNAMECREATE track.sopro.ioCHECKCHECK
CNAME2EMPTYN/AN/A
CNAME3EMPTYN/AN/A
CNAME4EMPTYN/AN/A
CNAME5EMPTYN/AN/A
MXCREATE 5× Google ASPMXCHECKCHECK
SPFCREATE include:_spf.google.com ~allCHECKCHECK
DKIMEMPTYUPDATE via CheckDKIMRecordN/ACHECK
DKIMSelectorCREATE google._domainkey
DMARCCREATE v=DMARC1; p=quarantine;CHECKCHECK
CompletedfalseUPDATE true

5.2 DNSRecord (SetupType=2)

Client-owned domain · Manual DNS. Same standard records at Init. DKIM from GSuite Admin SDK at SetComplete. DNS HTML emailed.

5.3 SMTPDetails (SetupType=3)

Tracking CNAME only. All others empty. domain.Type = null.

5.4 NameServerChange / SubdomainNsRecord

Same pattern — all standard at Init, DKIM at SetComplete. Pushed to CloudFlare / Route53.

📊 Live DB confirms: Every GSuite domain in production has identical MX (5 Google ASPMX), SPF (include:_spf.google.com ~all), CNAME (track.sopro.io), DMARC (v=DMARC1; p=quarantine;). The only varying field is DKIM.

6. Outlook — Per Setup Type

flowchart TD O1["Outlook · GoDaddy-owned"] --> O1a["Init: ALL standard records
MX: {d}.mail.protection.outlook.com
SPF: spf.protection.outlook.com -all (standard)
DMARC: v=DMARC1; p=quarantine (standard)
CNAME: track.sopro.io
CNAME5: autodiscover.outlook.com (standard)
DKIMSelector: selector1._domainkey
CNAME3/4: hostnames only"] O1a --> O1b["SetComplete: CNAME3/4 DKIM selectors
from Graph API (dynamic)"] O1b --> O1c["Nightly Completed=0:
CheckMX, CheckSPF, CheckDMARC,
CheckCNAME, CheckCNAME5
Nightly Completed=1:
+ CheckCNAME3/4"] O2["Outlook · DNSRecord (SetupType=2)"] --> O2a["Same Init values. Graph API →
CNAME3/4 DKIM at SetComplete.
DNS HTML emailed to Ops Owner."] style O1 fill:#e8f0fe,stroke:#1a73e8 style O2 fill:#e8f0fe,stroke:#1a73e8

6.1 GoDaddy-owned

ColumnInitSet CompleteNightly (C=0)Nightly (C=1)
CNAMECREATE track.sopro.ioCHECKCHECK
CNAME2EMPTYN/AN/A
CNAME3EMPTY (deferred)UPDATE DKIM CNAME from GraphN/ACHECK
CNAME4EMPTY (deferred)UPDATE DKIM CNAME2 from GraphN/ACHECK
CNAME5CREATE autodiscover.outlook.comCHECKCHECK
MXCREATE {d}.mail.protection.outlook.comCHECKCHECK
SPFCREATE spf.protection.outlook.com -allCHECKCHECK
DKIMN/A (CNAME-based)
DKIMSelectorCREATE selector1._domainkey
DMARCCREATE v=DMARC1; p=quarantine;CHECKCHECK

MX is domain-specific in format but predictable at Init — we know the pattern. CNAME3/4 are the only truly dynamic fields (created on Outlook server, fetched via Graph API).

6.2 DNSRecord (SetupType=2)

Client-owned · DNS HTML emailed. MX (domain-specific), SPF, DMARC, CNAME5 at Init. CNAME3/4 from Graph API at SetComplete.

6.3 Other Setup Types

Same pattern — pushed to CloudFlare / CNAME-only / Route53.

7. Postal — Per Setup Type

flowchart TD P1["Postal · GoDaddy-owned"] --> P1a["Init: EVERYTHING known upfront
MX: PostalMxRecord (from config)
SPF: PostalSpfRecord (from config)
DKIM: RSA key from PostalDomains
DMARC: v=DMARC1; p=quarantine
CNAME: track.sopro.io
CNAME2: PostalReturnPath
DKIMSelector: postal-{id}._domainkey"] P1a --> P1b["SetComplete: NOTHING to resolve
— all records already at Init.
Completed=true."] P1b --> P1c["Nightly Completed=0:
CheckMX, CheckSPF, CheckDMARC,
CheckCNAME, CheckDKIM (known upfront)
Nightly Completed=1: full check"] style P1 fill:#e8f0fe,stroke:#1a73e8

7.1 GoDaddy-owned

ColumnInitSet CompleteNightly (C=0)Nightly (C=1)
CNAMECREATE track.sopro.ioCHECKCHECK
CNAME2CREATE PostalReturnPathCHECKCHECK
CNAME3EMPTYN/AN/A
CNAME4EMPTYN/AN/A
CNAME5EMPTYN/AN/A
MXCREATE PostalMxRecordCHECKCHECK
SPFCREATE PostalSpfRecordCHECKCHECK
DKIMCREATE from PostalDomainsCHECKCHECK
DKIMSelectorCREATE postal-{id}._domainkey
DMARCCREATE v=DMARC1; p=quarantine;CHECKCHECK

💡 Postal is the cleanest — everything is known at Init. DKIM key from PostalDomains table, MX/SPF from GeneralSettings. Set Complete just marks Completed=true.

8. Hmail — Per Setup Type

8.1 GoDaddy-owned

ColumnInitSet CompleteNightly (C=0)Nightly (C=1)
CNAMECREATE track.sopro.ioCHECKCHECK
MXCREATE mx.soproserver.co.ukCHECKCHECK
SPFEMPTYUPDATE from Logins.Parameters2N/ACHECK
DKIMEMPTYUPDATE RSA keyN/ACHECK
DKIMSelectorCREATE google._domainkey
DMARCCREATE v=DMARC1; p=quarantine;CHECKCHECK
MX + DMARC are standard → Init. SPF from Logins.Parameters2 (varies per login) → SetComplete. DKIM is RSA TXT (dynamic) → SetComplete.

9. Nightly Check — Per-Column Verification Logic

🔍 Two-tier nightly check based on Completed flag — only non-deleted domains (Deleted=false):
Filter: _CheckDomainRecords queries DomainRecords.Where(x => x.Deleted == false). Soft-deleted domains are excluded entirely.
Completed = 0: Verify standard records only — MX (if non-empty), SPF (if non-empty), DMARC, CNAME, CNAME5. These were set at Init and should already be in DNS.
Completed = 1: Full verification — all of the above + DKIM. All DNS records are expected to be live.
🖱️ Manual DNS Check (DomainFlow Index): Each domain row in the DomainFlow grid has a "DNS Check" button (visible when Initialized == true). Clicking it calls CheckDNSRecordByDomain(Name, Type) → POST to /Domains/CheckDNSRecordHangFireDomain → enqueues _CheckDNSRecordsForDomainAsync(domainRecord, manualCheck: true).
Difference from scheduled: With manualCheck = true, the system always sends an email notification to the OpsOwner and IT admin with the results. Scheduled checks only notify on status changes (OK→ERROR or ERROR→OK).
flowchart LR N["_CheckDNSRecordsForDomainAsync"] --> N1["CheckMXRecords (if MX non-empty)"] N --> N2["CheckTxtRecords (SPF, if non-empty)"] N --> N3["CheckDmarcRecords"] N --> N4["CheckCnameRecords (emails + psrp)"] N --> N5["CheckMicrosoftCnameRecords (Outlook/Microsoft)"] N --> N6["CheckDkimRecords (Completed=1 OR Postal)"] N -.- R["All parallel (Task.WhenAll).
Completed=0: standard only.
Completed=1: full check.
Timeouts → skip domain."] style N fill:#e8f0fe,stroke:#1a73e8 style R fill:#fef7e0,stroke:#e37400
CheckMethodDNS QueryVerification LogicWhen
MXCheckMXRecordsQueryAsync(domain, MX)Parses stored MX (pipe-delimited). Each live MX must exist in expected set.If MX non-empty
SPFCheckTxtRecordsQueryAsync(domain, TXT)Case-insensitive match against stored SPF.If SPF non-empty
DMARCCheckDmarcRecordsQueryAsync(_dmarc.{d}, TXT)If no TXT → OK if stored DMARC empty. TXT exists → must match.Always
CNAME (emails)CheckCnameRecordsGetCnameRecordAsync(emails.{d})Resolved canonical must match stored CNAME.Always
CNAME2 (psrp)CheckCnameRecordsGetCnameRecordAsync(psrp.{d})Resolved canonical must match CNAME2. Skipped if empty.If non-empty
CNAME3-5CheckMicrosoftCnameRecordssel1/2._dk.{d} + autodiscover.{d}Resolved canonical must match stored CNAME3/4/5.Outlook/Microsoft; C=0: CNAME5 only; C=1: all
DKIMCheckDkimRecordsQueryAsync({sel}.{d}, TXT)TXT at DKIM selector must match stored DKIM.Completed=1 OR Postal (any Completed)
✅ Fixed: CNAME5 comparison bug in CheckMicrosoftCnameRecords — now correctly compares against domainRecords.CNAME5 (was reading CNAME3). Also fixed inverted error condition and broken .Any() + string.Join comparison for all three CNAME records.

10. Email Notifications — Per Lifecycle Event

Who gets emailed and when — for every domain lifecycle event.

⚠️ DNS check notifications are suppressed if errorsList.Count >= 5 (to avoid overwhelming recipients).

EventTriggerNotification MethodTemplate (ActionEnum)RecipientsWhen
Domain / Subdomain CreatedEditClientDomain or EditCampaignDomainMultipleSendDomainSubdomainNotificationSendDomainSubdomainMessage (purchased)
SendDomainSubdomainMessageClientOwned
SendDomainSubdomainMessageMain
OpsOwner, CS OwnerImmediately on save
Domain PurchasedEditCampaignDomainMultiple via GoDaddyPurchaseNewEmailDomainPurchaseNewEmailDomain (28)OpsOwnerAfter GoDaddy purchase succeeds. Guard: SendEmails=true & not already sent
Init (HangFire)"Init" button → InitHangFireDomainInsertDomainRecordsSkeletonNo email sent — only inserts skeleton row
DNS Records for ClientGoDaddy DNS push (HangFireDomainSettings)SendDomainDnsRecordsDomainDnsRecordsOpsOwnerWhen DNS records are pushed to GoDaddy for client-owned domains (SetupType=2)
Set Complete"Set Completed" button → StupidMethodDomainCompletedDomainCompletedDomainCompletedOpsOwnerOn Set Complete. Also sets domain.Completed=true + campaignDomain.Completed=true
Name Servers UpdatedCloudFlare / AWS nameserver pushSendDomainNameServersDomainNameservers or SubdomaininAWSnotificationOpsOwnerWhen nameservers are configured

10.1 DNS Check Notifications — Status Transition Logic

Called after every DNS check (scheduled nightly or manual button). The notification sent depends on manualCheck flag, old status, new status, and completed flag.

ScenarioOld → NewCompletedmanualCheckTemplate Sent
Manual check — records are correctOK/ERROR → OKtrueDNSFullyConfiguredAndCorrect
Manual check — recently added, not yet completeOK/ERROR → OKfalseDNSRecordsRecentlyAdded
Manual check — still misconfiguredERROR → ERRORanyDNSStillNotConfigured
Scheduled — misconfiguration detectedOK → ERRORanyDNSMisconfigurationDetected
Scheduled — recovered from error, completeERROR → OKtrueDNSFullyConfiguredAndCorrect
Scheduled — recovered from error, not completeERROR → OKfalseDNSRecordsRecentlyAdded
Scheduled — no status changeOK → OK or ERROR → ERRORanyNo email sent

💡 Key difference: Manual check always emails. Scheduled check only emails on status changes (OK→ERROR or ERROR→OK). All DNS check emails go to OpsOwner. Notifications are suppressed if 5+ errors are detected.

11. Master Summary Matrix

What each column gets, by phase, for each sending mechanism.

I = Init  ·  S = Set Complete  ·  N₀ = checked when Completed=0  ·  N₁ = checked when Completed=1

ColumnGSuiteOutlookPostalHmail
CNAMEI→N₀N₁I→N₀N₁I→N₀N₁I→N₀N₁
CNAME2emptyemptyI→N₀N₁ ReturnPathempty
CNAME3emptyS→N₁ DKIM sel1emptyempty
CNAME4emptyS→N₁ DKIM sel2emptyempty
CNAME5emptyI→N₀N₁ autodiscoveremptyempty
MXI→N₀N₁I→N₀N₁I→N₀N₁I→N₀N₁
SPFI→N₀N₁I→N₀N₁I→N₀N₁S→N₁ Logins
DKIMS→N₁N/A (CNAME-based)I→N₀N₁S→N₁
DKIMSelectorI google._domainkeyI selector1._domainkeyI postal-{id}._domainkeyI google._domainkey
DMARCI→N₀N₁I→N₀N₁I→N₀N₁I→N₀N₁
🔑 Key Takeaway: Within a sending mechanism, almost everything is the same for every domain. Init populates ~90% of all fields. Set Complete only resolves the truly dynamic/unique records: GSuite DKIM, Outlook CNAME3/4 (DKIM selectors from Graph API), Hmail SPF+DKIM. Nightly check is two-tier: Completed=0 verifies standard records only (MX, SPF, DMARC, CNAME, CNAME5); Completed=1 verifies everything including DKIM. Postal is the cleanest — all records known upfront at Init, nothing deferred.
✅ Fixed: CNAME5 bug in CheckMicrosoftCnameRecords — now compares against domainRecords.CNAME5 (was CNAME3). Also fixed inverted error condition and broken .Any() + string.Join comparison logic.