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 Mechanism | Always Same (→ Init) | Dynamic / Unique (→ Set Complete) |
|---|---|---|
| GSuite | MX, SPF, DMARC, CNAME, DKIMSelector | DKIM (unique RSA key per domain) |
| Outlook | MX, SPF, DMARC, CNAME, CNAME5, DKIMSelector | CNAME3, CNAME4 (DKIM selectors from Graph API) |
| Postal | MX, SPF, DMARC, CNAME, CNAME2, DKIM, DKIMSelector | Nothing — all known upfront from PostalDomains config |
| Hmail | MX, DMARC, CNAME, DKIMSelector | SPF (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
| # | Column | Type | Description | Populated When |
|---|---|---|---|---|
| 1 | Id | int (PK) | Auto-increment identity | Auto |
| 2 | DomainName | string | Full domain name, e.g. outreach.client.com | Init |
| 3 | DomainType | string(50) | "Domain" or "Subdomain" | Init |
| 4 | SendingMechanism | string(50) | GSuite · Outlook · Postal · Hmail · Other | Init |
| 5 | CNAME | string(500) | Tracking domain target. Resolved CNAME target from BrandSSL Logins, e.g. track.sopro.io. Same for all mechanisms. | Init (from BrandSSL Logins.Parameters2) |
| 6 | CNAME2 | string(500) | Postal return-path. psrp.{d}. Postal only. Empty for all others. | Init (Postal only) |
| 7 | CNAME3 | string(500) | DKIM selector 1 CNAME. Outlook only. Deferred to SetComplete — resolved from Graph API or live DNS. | SetComplete (Outlook only: DKIM CNAME target) |
| 8 | CNAME4 | string(500) | DKIM selector 2 CNAME. Outlook only. Deferred to SetComplete — resolved from Graph API or live DNS. | SetComplete (Outlook only: DKIM CNAME target) |
| 9 | CNAME5 | string(500) | Autodiscover. autodiscover.{d} → autodiscover.outlook.com. Outlook only. Empty for all others. | Init (standard: autodiscover.outlook.com) |
| 10 | MX | string(500) | MX records — pipe-delimited | Init (standard per mechanism, including Outlook) |
| 11 | SPF | string(500) | SPF TXT record | Init (GSuite/Outlook/Postal: standard) · SetComplete (Hmail: from Logins) |
| 12 | DKIM | string(500) | DKIM TXT value or RSA public key | Init (Postal: from PostalDomains) · SetComplete (GSuite/Hmail: unique per domain) |
| 13 | DKIMSelector | string(150) | DKIM selector prefix | Init (computed from mechanism) |
| 14 | DMARC | string(500) | DMARC TXT: v=DMARC1; p=quarantine; | Init (standard for all) |
| 15 | LastChecked | DateTime? | Timestamp of last DNS check | Init → Nightly |
| 16 | DomainStatus | string(50) | "OK" or "ERROR" | Nightly |
| 17 | Errors | string | Concatenated error messages | Nightly |
| 18 | Completed | bool | Whether domain setup is marked complete | SetComplete |
| 19 | Deleted | bool | Soft-delete flag | Default false |
3. Three Phases: Init → Set Complete → Nightly
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 → InitHangFireDomain → InsertDomainRecordsSkeleton
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
5. GSuite — Per Setup Type
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
| Column | Init | Set Complete | Nightly (C=0) | Nightly (C=1) |
|---|---|---|---|---|
| CNAME | CREATE track.sopro.io | — | CHECK | CHECK |
| CNAME2 | EMPTY | — | N/A | N/A |
| CNAME3 | EMPTY | — | N/A | N/A |
| CNAME4 | EMPTY | — | N/A | N/A |
| CNAME5 | EMPTY | — | N/A | N/A |
| MX | CREATE 5× Google ASPMX | — | CHECK | CHECK |
| SPF | CREATE include:_spf.google.com ~all | — | CHECK | CHECK |
| DKIM | EMPTY | UPDATE via CheckDKIMRecord | N/A | CHECK |
| DKIMSelector | CREATE google._domainkey | — | — | — |
| DMARC | CREATE v=DMARC1; p=quarantine; | — | CHECK | CHECK |
| Completed | false | UPDATE 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.
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
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
| Column | Init | Set Complete | Nightly (C=0) | Nightly (C=1) |
|---|---|---|---|---|
| CNAME | CREATE track.sopro.io | — | CHECK | CHECK |
| CNAME2 | EMPTY | — | N/A | N/A |
| CNAME3 | EMPTY (deferred) | UPDATE DKIM CNAME from Graph | N/A | CHECK |
| CNAME4 | EMPTY (deferred) | UPDATE DKIM CNAME2 from Graph | N/A | CHECK |
| CNAME5 | CREATE autodiscover.outlook.com | — | CHECK | CHECK |
| MX | CREATE {d}.mail.protection.outlook.com | — | CHECK | CHECK |
| SPF | CREATE spf.protection.outlook.com -all | — | CHECK | CHECK |
| DKIM | N/A (CNAME-based) | — | — | — |
| DKIMSelector | CREATE selector1._domainkey | — | — | — |
| DMARC | CREATE v=DMARC1; p=quarantine; | — | CHECK | CHECK |
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
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
| Column | Init | Set Complete | Nightly (C=0) | Nightly (C=1) |
|---|---|---|---|---|
| CNAME | CREATE track.sopro.io | — | CHECK | CHECK |
| CNAME2 | CREATE PostalReturnPath | — | CHECK | CHECK |
| CNAME3 | EMPTY | — | N/A | N/A |
| CNAME4 | EMPTY | — | N/A | N/A |
| CNAME5 | EMPTY | — | N/A | N/A |
| MX | CREATE PostalMxRecord | — | CHECK | CHECK |
| SPF | CREATE PostalSpfRecord | — | CHECK | CHECK |
| DKIM | CREATE from PostalDomains | — | CHECK | CHECK |
| DKIMSelector | CREATE postal-{id}._domainkey | — | — | — |
| DMARC | CREATE v=DMARC1; p=quarantine; | — | CHECK | CHECK |
💡 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
| Column | Init | Set Complete | Nightly (C=0) | Nightly (C=1) |
|---|---|---|---|---|
| CNAME | CREATE track.sopro.io | — | CHECK | CHECK |
| MX | CREATE mx.soproserver.co.uk | — | CHECK | CHECK |
| SPF | EMPTY | UPDATE from Logins.Parameters2 | N/A | CHECK |
| DKIM | EMPTY | UPDATE RSA key | N/A | CHECK |
| DKIMSelector | CREATE google._domainkey | — | — | — |
| DMARC | CREATE v=DMARC1; p=quarantine; | — | CHECK | CHECK |
| 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
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.
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).
Completed=0: standard only.
Completed=1: full check.
Timeouts → skip domain."] style N fill:#e8f0fe,stroke:#1a73e8 style R fill:#fef7e0,stroke:#e37400
| Check | Method | DNS Query | Verification Logic | When |
|---|---|---|---|---|
| MX | CheckMXRecords | QueryAsync(domain, MX) | Parses stored MX (pipe-delimited). Each live MX must exist in expected set. | If MX non-empty |
| SPF | CheckTxtRecords | QueryAsync(domain, TXT) | Case-insensitive match against stored SPF. | If SPF non-empty |
| DMARC | CheckDmarcRecords | QueryAsync(_dmarc.{d}, TXT) | If no TXT → OK if stored DMARC empty. TXT exists → must match. | Always |
| CNAME (emails) | CheckCnameRecords | GetCnameRecordAsync(emails.{d}) | Resolved canonical must match stored CNAME. | Always |
| CNAME2 (psrp) | CheckCnameRecords | GetCnameRecordAsync(psrp.{d}) | Resolved canonical must match CNAME2. Skipped if empty. | If non-empty |
| CNAME3-5 | CheckMicrosoftCnameRecords | sel1/2._dk.{d} + autodiscover.{d} | Resolved canonical must match stored CNAME3/4/5. | Outlook/Microsoft; C=0: CNAME5 only; C=1: all |
| DKIM | CheckDkimRecords | QueryAsync({sel}.{d}, TXT) | TXT at DKIM selector must match stored DKIM. | Completed=1 OR Postal (any Completed) |
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).
| Event | Trigger | Notification Method | Template (ActionEnum) | Recipients | When |
|---|---|---|---|---|---|
| Domain / Subdomain Created | EditClientDomain or EditCampaignDomainMultiple | SendDomainSubdomainNotification | SendDomainSubdomainMessage (purchased)SendDomainSubdomainMessageClientOwnedSendDomainSubdomainMessageMain | OpsOwner, CS Owner | Immediately on save |
| Domain Purchased | EditCampaignDomainMultiple via GoDaddy | PurchaseNewEmailDomain | PurchaseNewEmailDomain (28) | OpsOwner | After GoDaddy purchase succeeds. Guard: SendEmails=true & not already sent |
| Init (HangFire) | "Init" button → InitHangFireDomain → InsertDomainRecordsSkeleton | No email sent — only inserts skeleton row | — | — | |
| DNS Records for Client | GoDaddy DNS push (HangFireDomainSettings) | SendDomainDnsRecords | DomainDnsRecords | OpsOwner | When DNS records are pushed to GoDaddy for client-owned domains (SetupType=2) |
| Set Complete | "Set Completed" button → StupidMethod → DomainCompleted | DomainCompleted | DomainCompleted | OpsOwner | On Set Complete. Also sets domain.Completed=true + campaignDomain.Completed=true |
| Name Servers Updated | CloudFlare / AWS nameserver push | SendDomainNameServers | DomainNameservers or SubdomaininAWSnotification | OpsOwner | When 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.
| Scenario | Old → New | Completed | manualCheck | Template Sent |
|---|---|---|---|---|
| Manual check — records are correct | OK/ERROR → OK | true | ✅ | DNSFullyConfiguredAndCorrect |
| Manual check — recently added, not yet complete | OK/ERROR → OK | false | ✅ | DNSRecordsRecentlyAdded |
| Manual check — still misconfigured | ERROR → ERROR | any | ✅ | DNSStillNotConfigured |
| Scheduled — misconfiguration detected | OK → ERROR | any | ❌ | DNSMisconfigurationDetected |
| Scheduled — recovered from error, complete | ERROR → OK | true | ❌ | DNSFullyConfiguredAndCorrect |
| Scheduled — recovered from error, not complete | ERROR → OK | false | ❌ | DNSRecordsRecentlyAdded |
| Scheduled — no status change | OK → OK or ERROR → ERROR | any | ❌ | No 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
| Column | GSuite | Outlook | Postal | Hmail |
|---|---|---|---|---|
| CNAME | I→N₀N₁ | I→N₀N₁ | I→N₀N₁ | I→N₀N₁ |
| CNAME2 | empty | empty | I→N₀N₁ ReturnPath | empty |
| CNAME3 | empty | S→N₁ DKIM sel1 | empty | empty |
| CNAME4 | empty | S→N₁ DKIM sel2 | empty | empty |
| CNAME5 | empty | I→N₀N₁ autodiscover | empty | empty |
| MX | I→N₀N₁ | I→N₀N₁ | I→N₀N₁ | I→N₀N₁ |
| SPF | I→N₀N₁ | I→N₀N₁ | I→N₀N₁ | S→N₁ Logins |
| DKIM | S→N₁ | N/A (CNAME-based) | I→N₀N₁ | S→N₁ |
| DKIMSelector | I google._domainkey | I selector1._domainkey | I postal-{id}._domainkey | I google._domainkey |
| DMARC | I→N₀N₁ | I→N₀N₁ | I→N₀N₁ | I→N₀N₁ |
CheckMicrosoftCnameRecords — now compares against domainRecords.CNAME5 (was CNAME3). Also fixed inverted error condition and broken .Any() + string.Join comparison logic.