@aglyn/plugins-email 1.0.0-beta.221 → 1.0.0-beta.223
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- package/package.json +16 -17
- package/src/lib/components/email-template-detail.d.ts +5 -13
- package/src/lib/components/email-template-detail.js +29 -426
- package/src/lib/components/email-template-detail.js.map +1 -1
- package/src/lib/components/email-zones.d.ts +15 -0
- package/src/lib/components/email-zones.js +1 -0
- package/src/lib/components/email-zones.js.map +1 -1
- package/src/lib/components/list-detail-card.d.ts +0 -36
- package/src/lib/components/list-detail-card.js +125 -84
- package/src/lib/components/list-detail-card.js.map +1 -1
- package/src/lib/components/list-members-panel.d.ts +17 -0
- package/src/lib/components/list-members-panel.js +14 -0
- package/src/lib/components/list-members-panel.js.map +1 -1
- package/src/lib/components/site-package-email-preview.d.ts +13 -0
- package/src/lib/components/site-package-email-preview.js +47 -0
- package/src/lib/components/site-package-email-preview.js.map +1 -0
- package/src/lib/components/suppressions-card.js +75 -6
- package/src/lib/components/suppressions-card.js.map +1 -1
- package/src/lib/components/use-org-email-topics.js +2 -9
- package/src/lib/components/use-org-email-topics.js.map +1 -1
- package/src/lib/constants/site-package.d.ts +21 -0
- package/src/lib/constants/site-package.js +21 -0
- package/src/lib/constants/site-package.js.map +1 -0
- package/src/lib/declarations.console-server.d.ts +12 -0
- package/src/lib/declarations.console-server.js +73 -0
- package/src/lib/declarations.console-server.js.map +1 -1
- package/src/lib/list-import.d.ts +64 -75
- package/src/lib/list-import.js +43 -158
- package/src/lib/list-import.js.map +1 -1
- package/src/lib/model/email-topic-catalog.d.ts +24 -0
- package/src/lib/model/email-topic-catalog.js +19 -0
- package/src/lib/model/email-topic-catalog.js.map +1 -1
- package/src/lib/model/index.d.ts +0 -25
- package/src/lib/model/index.js +0 -27
- package/src/lib/model/index.js.map +1 -1
- package/src/lib/plugin.d.ts +8 -0
- package/src/lib/plugin.js +76 -2
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/person-eraser.d.ts +39 -0
- package/src/lib/server/person-eraser.js +44 -0
- package/src/lib/server/person-eraser.js.map +1 -0
- package/src/lib/server-console.js +9 -9
- package/src/lib/server-console.js.map +1 -1
- package/src/lib/server-list-gate.d.ts +7 -0
- package/src/lib/server-list-gate.js +10 -3
- package/src/lib/server-list-gate.js.map +1 -1
- package/src/lib/server-list-import.d.ts +20 -177
- package/src/lib/server-list-import.js +34 -594
- package/src/lib/server-list-import.js.map +1 -1
- package/src/lib/server.js +16 -29
- package/src/lib/server.js.map +1 -1
- package/src/lib/testing/stand-in-send-tally.d.ts +29 -0
- package/src/lib/testing/stand-in-send-tally.js +52 -0
- package/src/lib/testing/stand-in-send-tally.js.map +1 -0
- package/src/lib/transfer/email-transfer-catalog.d.ts +151 -0
- package/src/lib/transfer/email-transfer-catalog.js +536 -0
- package/src/lib/transfer/email-transfer-catalog.js.map +1 -0
- package/src/lib/transfer/list-import-steps.d.ts +44 -0
- package/src/lib/transfer/list-import-steps.js +201 -0
- package/src/lib/transfer/list-import-steps.js.map +1 -0
- package/src/lib/transfer/list-members.server.d.ts +165 -0
- package/src/lib/transfer/list-members.server.js +911 -0
- package/src/lib/transfer/list-members.server.js.map +1 -0
- package/src/lib/transfer/suppressions.server.d.ts +69 -0
- package/src/lib/transfer/suppressions.server.js +282 -0
- package/src/lib/transfer/suppressions.server.js.map +1 -0
- package/src/lib/transfer/topics-package.d.ts +56 -0
- package/src/lib/transfer/topics-package.js +87 -0
- package/src/lib/transfer/topics-package.js.map +1 -0
- package/src/lib/transfer/topics-package.server.d.ts +33 -0
- package/src/lib/transfer/topics-package.server.js +171 -0
- package/src/lib/transfer/topics-package.server.js.map +1 -0
- package/src/lib/transfer/transfer-reads.server.d.ts +25 -0
- package/src/lib/transfer/transfer-reads.server.js +34 -0
- package/src/lib/transfer/transfer-reads.server.js.map +1 -0
- package/src/lib/components/list-import-drawer.d.ts +0 -22
- package/src/lib/components/list-import-drawer.js +0 -662
- package/src/lib/components/list-import-drawer.js.map +0 -1
- package/src/lib/model/template-report.d.ts +0 -158
- package/src/lib/model/template-report.js +0 -249
- package/src/lib/model/template-report.js.map +0 -1
package/src/lib/list-import.d.ts
CHANGED
|
@@ -15,7 +15,52 @@
|
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
18
|
+
* WHAT A MERCHANT'S CONTACT FILE SAYS ABOUT ITSELF — the pure half of the
|
|
19
|
+
* list-member import's controls.
|
|
20
|
+
*
|
|
21
|
+
* `docs/specs/email-competitive-gaps.md` G5 is the product gap — a customer
|
|
22
|
+
* arriving with a list has no way to bring it — and P4 is its condition: an
|
|
23
|
+
* importer is simultaneously the biggest onboarding blocker and the biggest
|
|
24
|
+
* abuse vector, so it ships WITH its controls or not at all. The import runs
|
|
25
|
+
* on the platform's transfer framework (AGL-3529), which reads the file,
|
|
26
|
+
* maps its columns, collapses repeated addresses and reports the lines that
|
|
27
|
+
* are not addresses; this module is what the framework cannot know: the
|
|
28
|
+
* header spellings that mean an address, a name or a declared opt-in, and
|
|
29
|
+
* the two SCREENS. It decides nothing about consent and it enrolls nobody:
|
|
30
|
+
* the consent question belongs to `enrollment-basis`, which the one-address
|
|
31
|
+
* add path already asks, and an import that answered it a second way would be
|
|
32
|
+
* exactly the defect class the register has a P1 entry for.
|
|
33
|
+
*
|
|
34
|
+
* ## The screening reports, it never refuses
|
|
35
|
+
*
|
|
36
|
+
* M3AAWG's Vetting BCP supplies two cheap mechanical checks and both are
|
|
37
|
+
* signals rather than verdicts. A role account (`sales@`, `info@`) is, in
|
|
38
|
+
* M3AAWG's words, indicative of poor acquisition practice — it is not proof
|
|
39
|
+
* of one, and plenty of legitimate B2B lists carry a handful. A column named
|
|
40
|
+
* `jigsaw` or `append` is a purchase tell, and again a merchant may simply
|
|
41
|
+
* have named a column badly.
|
|
42
|
+
*
|
|
43
|
+
* So neither one drops an address. Both are put in front of the operator as
|
|
44
|
+
* warnings they acknowledge BEFORE the import writes, beside the statement of
|
|
45
|
+
* permission, because the attestation is the thing with teeth: an operator who
|
|
46
|
+
* states they have permission for a file whose headers say it was appended
|
|
47
|
+
* has made a claim their own console showed them the evidence against. That
|
|
48
|
+
* is what makes the record worth keeping.
|
|
49
|
+
*
|
|
50
|
+
* ## A declared basis per address, and why it is still an assertion
|
|
51
|
+
*
|
|
52
|
+
* P4 asks for "a declared basis per address, not one checkbox over the file".
|
|
53
|
+
* A file may carry an opt-in source and an opt-in date per row, and when it
|
|
54
|
+
* does they are read and carried onto the membership as evidence. They do
|
|
55
|
+
* NOT become the person's own opt-in. Nothing arriving in a spreadsheet is a
|
|
56
|
+
* checkbox somebody ticked; it is the merchant telling us about one, which is
|
|
57
|
+
* an operator assertion however many columns it arrives in. The basis stays
|
|
58
|
+
* `operator-attested` and the declared source rides along as the reason.
|
|
59
|
+
*/
|
|
60
|
+
/**
|
|
61
|
+
* The most rows one uploaded file may carry — the list-member resource's
|
|
62
|
+
* `limits.maxRows` in `plugins.config.json`, which the transfer engine
|
|
63
|
+
* enforces at the upload (`list-import.spec.ts` holds the two equal).
|
|
19
64
|
*
|
|
20
65
|
* A bound on the WORK an import represents, not a capacity limit: it refuses
|
|
21
66
|
* a FILE, before anything is written, and it can never drop a person already
|
|
@@ -25,8 +70,7 @@
|
|
|
25
70
|
*
|
|
26
71
|
* 50,000 is Klaviyo's shape rather than a number of our own — its only
|
|
27
72
|
* published hard import limit is a 50 MB CSV, which is the same statement in
|
|
28
|
-
* bytes.
|
|
29
|
-
* request.
|
|
73
|
+
* bytes.
|
|
30
74
|
*/
|
|
31
75
|
export declare const LIST_IMPORT_MAX_ADDRESSES = 50000;
|
|
32
76
|
/**
|
|
@@ -58,83 +102,28 @@ export declare const ROLE_ACCOUNT_LOCAL_PARTS: readonly string[];
|
|
|
58
102
|
* having been shown it.
|
|
59
103
|
*/
|
|
60
104
|
export declare const PURCHASE_TELL_COLUMNS: readonly string[];
|
|
61
|
-
/**
|
|
62
|
-
export
|
|
63
|
-
/** The source line, verbatim, so a bad one can be pointed at on screen. */
|
|
64
|
-
input: string;
|
|
65
|
-
/** The normalized address, or `null` when the line does not carry one. */
|
|
66
|
-
email: string | null;
|
|
67
|
-
/** A display name from the file, or `''`. */
|
|
68
|
-
name: string;
|
|
69
|
-
/**
|
|
70
|
-
* Where the file says this person opted in, or `''`.
|
|
71
|
-
*
|
|
72
|
-
* Evidence for the operator's assertion and never a basis of its own — see
|
|
73
|
-
* the module note.
|
|
74
|
-
*/
|
|
75
|
-
declaredSource: string;
|
|
76
|
-
/** When the file says they opted in, or `''`, kept as written. */
|
|
77
|
-
declaredAt: string;
|
|
78
|
-
}
|
|
79
|
-
/** What one file turned out to be. */
|
|
80
|
-
export interface ParsedListImport {
|
|
81
|
-
/** The header row's column names, or `[]` for a bare list of addresses. */
|
|
82
|
-
columns: string[];
|
|
83
|
-
/**
|
|
84
|
-
* One row per usable, first-seen address, plus one per unusable line.
|
|
85
|
-
*
|
|
86
|
-
* A repeat of an address already seen is collapsed and counted in
|
|
87
|
-
* {@link duplicates} rather than carried: the membership is keyed by the
|
|
88
|
-
* normalized address, so two lines would be one row and reporting them as
|
|
89
|
-
* two would tell the operator they are attesting for more people than they
|
|
90
|
-
* are. That is exactly the rule `resolveAddresses` applies to a paste.
|
|
91
|
-
*/
|
|
92
|
-
rows: ListImportRow[];
|
|
93
|
-
/** Rows carrying a usable address. */
|
|
94
|
-
usable: number;
|
|
95
|
-
/** Lines that are not addresses. Reported, never dropped. */
|
|
96
|
-
unusable: number;
|
|
97
|
-
/** Repeat appearances of an address already counted. */
|
|
98
|
-
duplicates: number;
|
|
99
|
-
/** True when the file names more than {@link LIST_IMPORT_MAX_ADDRESSES}. */
|
|
100
|
-
overCeiling: boolean;
|
|
101
|
-
}
|
|
102
|
-
/** What the mechanical screening found. Signals, not verdicts. */
|
|
103
|
-
export interface ListImportScreening {
|
|
104
|
-
/** Addresses at a shared or unattended mailbox. */
|
|
105
|
-
roleAccounts: string[];
|
|
106
|
-
/** Column names in the file that read as purchase or append tells. */
|
|
107
|
-
purchaseTellColumns: string[];
|
|
108
|
-
/** True when the file declares an opt-in source or date per row. */
|
|
109
|
-
declaresBasis: boolean;
|
|
110
|
-
}
|
|
105
|
+
/** Header spellings that name the address column. */
|
|
106
|
+
export declare const LIST_IMPORT_EMAIL_COLUMNS: readonly string[];
|
|
111
107
|
/**
|
|
112
|
-
*
|
|
108
|
+
* Header spellings that name the member's display name.
|
|
113
109
|
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* addresses IS a single-column CSV, and a second code path for it would be a
|
|
118
|
-
* second place for the quoting rules to differ.
|
|
119
|
-
*
|
|
120
|
-
* ## Every line comes back, including the bad ones
|
|
121
|
-
*
|
|
122
|
-
* A file that silently discarded its malformed lines would tell an operator
|
|
123
|
-
* that 100 addresses went on the list when 94 did, and the six they never
|
|
124
|
-
* hear about are the six they typed wrong. This is the rule `resolveAddresses`
|
|
125
|
-
* already applies to a paste, restated for a file.
|
|
110
|
+
* Not `First name`: a file that splits the name maps its parts to the
|
|
111
|
+
* contact record's first and last name, and the membership's name is
|
|
112
|
+
* composed from them when no column names it whole.
|
|
126
113
|
*/
|
|
127
|
-
export declare
|
|
114
|
+
export declare const LIST_IMPORT_NAME_COLUMNS: readonly string[];
|
|
115
|
+
/** Header spellings carrying the merchant's declared opt-in source. */
|
|
116
|
+
export declare const LIST_IMPORT_OPT_IN_SOURCE_COLUMNS: readonly string[];
|
|
117
|
+
/** Header spellings carrying the merchant's declared opt-in date. */
|
|
118
|
+
export declare const LIST_IMPORT_OPT_IN_DATE_COLUMNS: readonly string[];
|
|
119
|
+
/** Whether a normalized address is at a shared or unattended mailbox. */
|
|
120
|
+
export declare function isRoleAccount(email: string | null | undefined): boolean;
|
|
128
121
|
/**
|
|
129
|
-
* The
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
* address. It is deliberately not a deliverability service — Omnisend's own
|
|
133
|
-
* caveat on the one that exists is that "list cleaning confirms
|
|
134
|
-
* deliverability only and does not establish recipient consent", and consent
|
|
135
|
-
* is the only question this import actually turns on.
|
|
122
|
+
* The file's column names that read as purchase or append tells — every
|
|
123
|
+
* column, mapped or not: a column the operator chose to ignore still says
|
|
124
|
+
* where the file came from.
|
|
136
125
|
*/
|
|
137
|
-
export declare function
|
|
126
|
+
export declare function purchaseTellColumns(headers: readonly string[]): string[];
|
|
138
127
|
/**
|
|
139
128
|
* The sentence recorded as the REASON behind an imported person's basis.
|
|
140
129
|
*
|
package/src/lib/list-import.js
CHANGED
|
@@ -14,15 +14,19 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ /**
|
|
17
|
-
*
|
|
17
|
+
* WHAT A MERCHANT'S CONTACT FILE SAYS ABOUT ITSELF — the pure half of the
|
|
18
|
+
* list-member import's controls.
|
|
18
19
|
*
|
|
19
20
|
* `docs/specs/email-competitive-gaps.md` G5 is the product gap — a customer
|
|
20
21
|
* arriving with a list has no way to bring it — and P4 is its condition: an
|
|
21
22
|
* importer is simultaneously the biggest onboarding blocker and the biggest
|
|
22
|
-
* abuse vector, so it ships WITH its controls or not at all.
|
|
23
|
-
* the
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* abuse vector, so it ships WITH its controls or not at all. The import runs
|
|
24
|
+
* on the platform's transfer framework (AGL-3529), which reads the file,
|
|
25
|
+
* maps its columns, collapses repeated addresses and reports the lines that
|
|
26
|
+
* are not addresses; this module is what the framework cannot know: the
|
|
27
|
+
* header spellings that mean an address, a name or a declared opt-in, and
|
|
28
|
+
* the two SCREENS. It decides nothing about consent and it enrolls nobody:
|
|
29
|
+
* the consent question belongs to `enrollment-basis`, which the one-address
|
|
26
30
|
* add path already asks, and an import that answered it a second way would be
|
|
27
31
|
* exactly the defect class the register has a P1 entry for.
|
|
28
32
|
*
|
|
@@ -35,25 +39,26 @@
|
|
|
35
39
|
* `jigsaw` or `append` is a purchase tell, and again a merchant may simply
|
|
36
40
|
* have named a column badly.
|
|
37
41
|
*
|
|
38
|
-
* So neither one drops an address. Both are put in front of the operator
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
42
|
+
* So neither one drops an address. Both are put in front of the operator as
|
|
43
|
+
* warnings they acknowledge BEFORE the import writes, beside the statement of
|
|
44
|
+
* permission, because the attestation is the thing with teeth: an operator who
|
|
45
|
+
* states they have permission for a file whose headers say it was appended
|
|
46
|
+
* has made a claim their own console showed them the evidence against. That
|
|
47
|
+
* is what makes the record worth keeping.
|
|
43
48
|
*
|
|
44
49
|
* ## A declared basis per address, and why it is still an assertion
|
|
45
50
|
*
|
|
46
51
|
* P4 asks for "a declared basis per address, not one checkbox over the file".
|
|
47
52
|
* A file may carry an opt-in source and an opt-in date per row, and when it
|
|
48
|
-
* does they are read
|
|
53
|
+
* does they are read and carried onto the membership as evidence. They do
|
|
49
54
|
* NOT become the person's own opt-in. Nothing arriving in a spreadsheet is a
|
|
50
55
|
* checkbox somebody ticked; it is the merchant telling us about one, which is
|
|
51
56
|
* an operator assertion however many columns it arrives in. The basis stays
|
|
52
57
|
* `operator-attested` and the declared source rides along as the reason.
|
|
53
|
-
*/
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
*
|
|
58
|
+
*/ /**
|
|
59
|
+
* The most rows one uploaded file may carry — the list-member resource's
|
|
60
|
+
* `limits.maxRows` in `plugins.config.json`, which the transfer engine
|
|
61
|
+
* enforces at the upload (`list-import.spec.ts` holds the two equal).
|
|
57
62
|
*
|
|
58
63
|
* A bound on the WORK an import represents, not a capacity limit: it refuses
|
|
59
64
|
* a FILE, before anything is written, and it can never drop a person already
|
|
@@ -63,8 +68,7 @@ import { parseCsv } from "@aglyn/aglyn/app-utils/csv";
|
|
|
63
68
|
*
|
|
64
69
|
* 50,000 is Klaviyo's shape rather than a number of our own — its only
|
|
65
70
|
* published hard import limit is a 50 MB CSV, which is the same statement in
|
|
66
|
-
* bytes.
|
|
67
|
-
* request.
|
|
71
|
+
* bytes.
|
|
68
72
|
*/ export const LIST_IMPORT_MAX_ADDRESSES = 50000;
|
|
69
73
|
/**
|
|
70
74
|
* Local parts that make an address a ROLE account.
|
|
@@ -127,7 +131,7 @@ import { parseCsv } from "@aglyn/aglyn/app-utils/csv";
|
|
|
127
131
|
'lead gen',
|
|
128
132
|
'scraped'
|
|
129
133
|
];
|
|
130
|
-
/** Header
|
|
134
|
+
/** Header spellings that name the address column. */ export const LIST_IMPORT_EMAIL_COLUMNS = [
|
|
131
135
|
'email',
|
|
132
136
|
'emailaddress',
|
|
133
137
|
'email address',
|
|
@@ -137,17 +141,20 @@ import { parseCsv } from "@aglyn/aglyn/app-utils/csv";
|
|
|
137
141
|
'contact email',
|
|
138
142
|
'primary email'
|
|
139
143
|
];
|
|
140
|
-
/**
|
|
144
|
+
/**
|
|
145
|
+
* Header spellings that name the member's display name.
|
|
146
|
+
*
|
|
147
|
+
* Not `First name`: a file that splits the name maps its parts to the
|
|
148
|
+
* contact record's first and last name, and the membership's name is
|
|
149
|
+
* composed from them when no column names it whole.
|
|
150
|
+
*/ export const LIST_IMPORT_NAME_COLUMNS = [
|
|
141
151
|
'name',
|
|
142
152
|
'full name',
|
|
143
153
|
'fullname',
|
|
144
154
|
'display name',
|
|
145
|
-
'contact name'
|
|
146
|
-
'first name',
|
|
147
|
-
'firstname',
|
|
148
|
-
'given name'
|
|
155
|
+
'contact name'
|
|
149
156
|
];
|
|
150
|
-
/** Header
|
|
157
|
+
/** Header spellings carrying the merchant's declared opt-in source. */ export const LIST_IMPORT_OPT_IN_SOURCE_COLUMNS = [
|
|
151
158
|
'opt-in source',
|
|
152
159
|
'optin source',
|
|
153
160
|
'opt in source',
|
|
@@ -156,7 +163,7 @@ import { parseCsv } from "@aglyn/aglyn/app-utils/csv";
|
|
|
156
163
|
'subscription source',
|
|
157
164
|
'source'
|
|
158
165
|
];
|
|
159
|
-
/** Header
|
|
166
|
+
/** Header spellings carrying the merchant's declared opt-in date. */ export const LIST_IMPORT_OPT_IN_DATE_COLUMNS = [
|
|
160
167
|
'opt-in date',
|
|
161
168
|
'optin date',
|
|
162
169
|
'opt in date',
|
|
@@ -166,146 +173,24 @@ import { parseCsv } from "@aglyn/aglyn/app-utils/csv";
|
|
|
166
173
|
'date subscribed',
|
|
167
174
|
'confirmed at'
|
|
168
175
|
];
|
|
169
|
-
/** Normalizes a header cell for
|
|
176
|
+
/** Normalizes a header cell for the purchase-tell match. */ function headerKey(value) {
|
|
170
177
|
return String(value != null ? value : '').trim().toLowerCase().replace(/[_]+/g, ' ').replace(/\s+/g, ' ');
|
|
171
178
|
}
|
|
172
|
-
/**
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
178
|
-
return -1;
|
|
179
|
-
}
|
|
180
|
-
/**
|
|
181
|
-
* Whether a parsed first row is a HEADER rather than a person.
|
|
182
|
-
*
|
|
183
|
-
* A header is a row with no address in it. That test rather than an alias
|
|
184
|
-
* match, because the common shape of a merchant's export is a header whose
|
|
185
|
-
* address column is called something we have never seen — and treating that
|
|
186
|
-
* file as headerless would import the word "Email Address" as a line that is
|
|
187
|
-
* not an address, and shift every subsequent column read by one.
|
|
188
|
-
*
|
|
189
|
-
* A one-column file of bare addresses therefore has no header, correctly:
|
|
190
|
-
* every row including the first carries an address.
|
|
191
|
-
*/ function looksLikeHeader(cells) {
|
|
192
|
-
return !cells.some((cell)=>normalizeContactEmail(cell));
|
|
193
|
-
}
|
|
194
|
-
/**
|
|
195
|
-
* Reads an uploaded or pasted file into rows.
|
|
196
|
-
*
|
|
197
|
-
* Handles the two shapes a merchant's list actually arrives in: a CSV with a
|
|
198
|
-
* header, and a bare column of addresses with no header at all. Both go
|
|
199
|
-
* through one parser — `parseCsv` — because a newline-separated list of
|
|
200
|
-
* addresses IS a single-column CSV, and a second code path for it would be a
|
|
201
|
-
* second place for the quoting rules to differ.
|
|
202
|
-
*
|
|
203
|
-
* ## Every line comes back, including the bad ones
|
|
204
|
-
*
|
|
205
|
-
* A file that silently discarded its malformed lines would tell an operator
|
|
206
|
-
* that 100 addresses went on the list when 94 did, and the six they never
|
|
207
|
-
* hear about are the six they typed wrong. This is the rule `resolveAddresses`
|
|
208
|
-
* already applies to a paste, restated for a file.
|
|
209
|
-
*/ export function parseListImport(text) {
|
|
210
|
-
const table = parseCsv(String(text != null ? text : ''));
|
|
211
|
-
if (!table.length) {
|
|
212
|
-
return {
|
|
213
|
-
columns: [],
|
|
214
|
-
rows: [],
|
|
215
|
-
usable: 0,
|
|
216
|
-
unusable: 0,
|
|
217
|
-
duplicates: 0,
|
|
218
|
-
overCeiling: false
|
|
219
|
-
};
|
|
220
|
-
}
|
|
221
|
-
const headed = table.length > 1 && looksLikeHeader(table[0]);
|
|
222
|
-
const columns = headed ? table[0].map((cell)=>String(cell != null ? cell : '').trim()) : [];
|
|
223
|
-
const body = headed ? table.slice(1) : table;
|
|
224
|
-
const emailAt = headed ? columnIndex(columns, EMAIL_COLUMNS) : 0;
|
|
225
|
-
const nameAt = headed ? columnIndex(columns, NAME_COLUMNS) : -1;
|
|
226
|
-
const sourceAt = headed ? columnIndex(columns, OPT_IN_SOURCE_COLUMNS) : -1;
|
|
227
|
-
const dateAt = headed ? columnIndex(columns, OPT_IN_DATE_COLUMNS) : -1;
|
|
228
|
-
const seen = new Set();
|
|
229
|
-
const rows = [];
|
|
230
|
-
let usable = 0;
|
|
231
|
-
let unusable = 0;
|
|
232
|
-
let duplicates = 0;
|
|
233
|
-
let overCeiling = false;
|
|
234
|
-
for (const cells of body){
|
|
235
|
-
var _cells_emailAt, _cells_find, _cells_nameAt, _cells_sourceAt, _cells_dateAt;
|
|
236
|
-
/*
|
|
237
|
-
* The named column when the header named one, and otherwise the first
|
|
238
|
-
* cell in the row that IS an address.
|
|
239
|
-
*
|
|
240
|
-
* The fallback is what makes an unrecognized header survivable: an
|
|
241
|
-
* export whose address column is called `Primary contact e-mail (work)`
|
|
242
|
-
* matches no alias, and refusing the whole file over a column name would
|
|
243
|
-
* be refusing the customer's migration over our vocabulary.
|
|
244
|
-
*/ const raw = emailAt >= 0 && emailAt < cells.length ? String((_cells_emailAt = cells[emailAt]) != null ? _cells_emailAt : '') : (_cells_find = cells.find((cell)=>normalizeContactEmail(cell))) != null ? _cells_find : '';
|
|
245
|
-
const email = normalizeContactEmail(raw);
|
|
246
|
-
const input = String(raw != null ? raw : '').trim() || cells.join(', ').trim();
|
|
247
|
-
if (!email) {
|
|
248
|
-
unusable += 1;
|
|
249
|
-
rows.push({
|
|
250
|
-
input,
|
|
251
|
-
email: null,
|
|
252
|
-
name: '',
|
|
253
|
-
declaredSource: '',
|
|
254
|
-
declaredAt: ''
|
|
255
|
-
});
|
|
256
|
-
continue;
|
|
257
|
-
}
|
|
258
|
-
if (seen.has(email)) {
|
|
259
|
-
duplicates += 1;
|
|
260
|
-
continue;
|
|
261
|
-
}
|
|
262
|
-
if (seen.size >= LIST_IMPORT_MAX_ADDRESSES) {
|
|
263
|
-
overCeiling = true;
|
|
264
|
-
break;
|
|
265
|
-
}
|
|
266
|
-
seen.add(email);
|
|
267
|
-
usable += 1;
|
|
268
|
-
rows.push({
|
|
269
|
-
input,
|
|
270
|
-
email,
|
|
271
|
-
name: nameAt >= 0 ? String((_cells_nameAt = cells[nameAt]) != null ? _cells_nameAt : '').trim() : '',
|
|
272
|
-
declaredSource: sourceAt >= 0 ? String((_cells_sourceAt = cells[sourceAt]) != null ? _cells_sourceAt : '').trim() : '',
|
|
273
|
-
declaredAt: dateAt >= 0 ? String((_cells_dateAt = cells[dateAt]) != null ? _cells_dateAt : '').trim() : ''
|
|
274
|
-
});
|
|
275
|
-
}
|
|
276
|
-
return {
|
|
277
|
-
columns,
|
|
278
|
-
rows,
|
|
279
|
-
usable,
|
|
280
|
-
unusable,
|
|
281
|
-
duplicates,
|
|
282
|
-
overCeiling
|
|
283
|
-
};
|
|
179
|
+
/** Whether a normalized address is at a shared or unattended mailbox. */ export function isRoleAccount(email) {
|
|
180
|
+
const address = String(email != null ? email : '');
|
|
181
|
+
const at = address.indexOf('@');
|
|
182
|
+
if (at <= 0) return false;
|
|
183
|
+
return ROLE_ACCOUNT_LOCAL_PARTS.includes(address.slice(0, at).toLowerCase());
|
|
284
184
|
}
|
|
285
185
|
/**
|
|
286
|
-
* The
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
* deliverability only and does not establish recipient consent", and consent
|
|
292
|
-
* is the only question this import actually turns on.
|
|
293
|
-
*/ export function screenListImport(parsed) {
|
|
294
|
-
const roleAccounts = [];
|
|
295
|
-
for (const row of parsed.rows){
|
|
296
|
-
if (!row.email) continue;
|
|
297
|
-
const local = row.email.slice(0, row.email.indexOf('@'));
|
|
298
|
-
if (ROLE_ACCOUNT_LOCAL_PARTS.includes(local)) roleAccounts.push(row.email);
|
|
299
|
-
}
|
|
300
|
-
const purchaseTellColumns = parsed.columns.filter((column)=>{
|
|
186
|
+
* The file's column names that read as purchase or append tells — every
|
|
187
|
+
* column, mapped or not: a column the operator chose to ignore still says
|
|
188
|
+
* where the file came from.
|
|
189
|
+
*/ export function purchaseTellColumns(headers) {
|
|
190
|
+
return headers.filter((column)=>{
|
|
301
191
|
const key = headerKey(column);
|
|
302
192
|
return PURCHASE_TELL_COLUMNS.some((tell)=>key.includes(tell));
|
|
303
193
|
});
|
|
304
|
-
return {
|
|
305
|
-
roleAccounts,
|
|
306
|
-
purchaseTellColumns,
|
|
307
|
-
declaresBasis: parsed.rows.some((row)=>!!row.declaredSource || !!row.declaredAt)
|
|
308
|
-
};
|
|
309
194
|
}
|
|
310
195
|
/**
|
|
311
196
|
* The sentence recorded as the REASON behind an imported person's basis.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/list-import.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * READING A MERCHANT'S CONTACT FILE, and saying what is in it.\n *\n * `docs/specs/email-competitive-gaps.md` G5 is the product gap — a customer\n * arriving with a list has no way to bring it — and P4 is its condition: an\n * importer is simultaneously the biggest onboarding blocker and the biggest\n * abuse vector, so it ships WITH its controls or not at all. This module is\n * the pure half of those controls. It parses, it de-duplicates, and it\n * SCREENS. It decides nothing about consent and it enrolls nobody: the\n * consent question belongs to `enrollment-basis`, which the one-address\n * add path already asks, and an import that answered it a second way would be\n * exactly the defect class the register has a P1 entry for.\n *\n * ## The screening reports, it never refuses\n *\n * M3AAWG's Vetting BCP supplies two cheap mechanical checks and both are\n * signals rather than verdicts. A role account (`sales@`, `info@`) is, in\n * M3AAWG's words, indicative of poor acquisition practice — it is not proof\n * of one, and plenty of legitimate B2B lists carry a handful. A column named\n * `jigsaw` or `append` is a purchase tell, and again a merchant may simply\n * have named a column badly.\n *\n * So neither one drops an address. Both are put in front of the operator\n * BEFORE they attest, because the attestation is the thing with teeth: an\n * operator who states they have permission for a file whose headers say it\n * was appended has made a claim their own console showed them the evidence\n * against. That is what makes the record worth keeping.\n *\n * ## A declared basis per address, and why it is still an assertion\n *\n * P4 asks for \"a declared basis per address, not one checkbox over the file\".\n * A file may carry an opt-in source and an opt-in date per row, and when it\n * does they are read here and carried onto the import as evidence. They do\n * NOT become the person's own opt-in. Nothing arriving in a spreadsheet is a\n * checkbox somebody ticked; it is the merchant telling us about one, which is\n * an operator assertion however many columns it arrives in. The basis stays\n * `operator-attested` and the declared source rides along as the reason.\n */\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { parseCsv } from '@aglyn/aglyn/app-utils/csv'\n\n/**\n * The most addresses one uploaded file may name.\n *\n * A bound on the WORK an import represents, not a capacity limit: it refuses\n * a FILE, before anything is written, and it can never drop a person already\n * on a list or trim one to make room. A merchant with more than this splits\n * the file, which is a nuisance; a merchant whose audience got silently\n * truncated has an audience they cannot reason about.\n *\n * 50,000 is Klaviyo's shape rather than a number of our own — its only\n * published hard import limit is a 50 MB CSV, which is the same statement in\n * bytes. Above this the parse itself is the cost, and it happens in one\n * request.\n */\nexport const LIST_IMPORT_MAX_ADDRESSES = 50_000\n\n/**\n * Local parts that make an address a ROLE account.\n *\n * A mailbox several people read, or none. M3AAWG's Vetting BCP: their\n * appearance on a customer list \"may be indicative of poor acquisition\n * practices\" — a list built by scraping a website collects these, a list\n * built from signups does not.\n *\n * Reported, never dropped. A merchant importing their own supplier contacts\n * legitimately has `orders@`, and an importer that quietly removed addresses\n * would be telling the operator a different number went on the list than did.\n */\nexport const ROLE_ACCOUNT_LOCAL_PARTS: readonly string[] = [\n 'abuse',\n 'admin',\n 'billing',\n 'contact',\n 'enquiries',\n 'help',\n 'hello',\n 'hr',\n 'info',\n 'inquiries',\n 'mail',\n 'marketing',\n 'noreply',\n 'no-reply',\n 'office',\n 'orders',\n 'postmaster',\n 'sales',\n 'security',\n 'staff',\n 'support',\n 'team',\n 'webmaster',\n]\n\n/**\n * Column names that say where the file came from, when the answer is \"not\n * from people who asked\".\n *\n * M3AAWG names `jigsaw` and `append` specifically. The rest are the same tell\n * in the vocabulary the data brokers actually use, and they are matched as\n * substrings of a normalized header so `Appended Email` and `append_date`\n * both hit.\n *\n * Every vendor surveyed prohibits purchased lists outright, Apple bans\n * purchased, rented and appended lists, and M3AAWG calls appending \"a direct\n * violation of core M3AAWG values\". This is not a policy we invented and the\n * screening exists so that a merchant cannot attest their way past it without\n * having been shown it.\n */\nexport const PURCHASE_TELL_COLUMNS: readonly string[] = [\n 'jigsaw',\n 'append',\n 'purchased',\n 'rented',\n 'databroker',\n 'data broker',\n 'leadgen',\n 'lead gen',\n 'scraped',\n]\n\n/** Header aliases that name the address column. */\nconst EMAIL_COLUMNS = [\n 'email',\n 'emailaddress',\n 'email address',\n 'e-mail',\n 'e-mail address',\n 'mail',\n 'contact email',\n 'primary email',\n]\n\n/** Header aliases that name a display name. */\nconst NAME_COLUMNS = [\n 'name',\n 'full name',\n 'fullname',\n 'display name',\n 'contact name',\n 'first name',\n 'firstname',\n 'given name',\n]\n\n/** Header aliases carrying the merchant's declared opt-in source. */\nconst OPT_IN_SOURCE_COLUMNS = [\n 'opt-in source',\n 'optin source',\n 'opt in source',\n 'consent source',\n 'signup source',\n 'subscription source',\n 'source',\n]\n\n/** Header aliases carrying the merchant's declared opt-in date. */\nconst OPT_IN_DATE_COLUMNS = [\n 'opt-in date',\n 'optin date',\n 'opt in date',\n 'consent date',\n 'signup date',\n 'subscribed at',\n 'date subscribed',\n 'confirmed at',\n]\n\n/** What the merchant's file says about one person. */\nexport interface ListImportRow {\n /** The source line, verbatim, so a bad one can be pointed at on screen. */\n input: string\n /** The normalized address, or `null` when the line does not carry one. */\n email: string | null\n /** A display name from the file, or `''`. */\n name: string\n /**\n * Where the file says this person opted in, or `''`.\n *\n * Evidence for the operator's assertion and never a basis of its own — see\n * the module note.\n */\n declaredSource: string\n /** When the file says they opted in, or `''`, kept as written. */\n declaredAt: string\n}\n\n/** What one file turned out to be. */\nexport interface ParsedListImport {\n /** The header row's column names, or `[]` for a bare list of addresses. */\n columns: string[]\n /**\n * One row per usable, first-seen address, plus one per unusable line.\n *\n * A repeat of an address already seen is collapsed and counted in\n * {@link duplicates} rather than carried: the membership is keyed by the\n * normalized address, so two lines would be one row and reporting them as\n * two would tell the operator they are attesting for more people than they\n * are. That is exactly the rule `resolveAddresses` applies to a paste.\n */\n rows: ListImportRow[]\n /** Rows carrying a usable address. */\n usable: number\n /** Lines that are not addresses. Reported, never dropped. */\n unusable: number\n /** Repeat appearances of an address already counted. */\n duplicates: number\n /** True when the file names more than {@link LIST_IMPORT_MAX_ADDRESSES}. */\n overCeiling: boolean\n}\n\n/** What the mechanical screening found. Signals, not verdicts. */\nexport interface ListImportScreening {\n /** Addresses at a shared or unattended mailbox. */\n roleAccounts: string[]\n /** Column names in the file that read as purchase or append tells. */\n purchaseTellColumns: string[]\n /** True when the file declares an opt-in source or date per row. */\n declaresBasis: boolean\n}\n\n/** Normalizes a header cell for alias matching. */\nfunction headerKey(value: string): string {\n return String(value ?? '')\n .trim()\n .toLowerCase()\n .replace(/[_]+/g, ' ')\n .replace(/\\s+/g, ' ')\n}\n\n/** The index of the first column matching one of `aliases`, or -1. */\nfunction columnIndex(columns: string[], aliases: readonly string[]): number {\n const keys = columns.map(headerKey)\n for (const alias of aliases) {\n const at = keys.indexOf(alias)\n if (at !== -1) return at\n }\n return -1\n}\n\n/**\n * Whether a parsed first row is a HEADER rather than a person.\n *\n * A header is a row with no address in it. That test rather than an alias\n * match, because the common shape of a merchant's export is a header whose\n * address column is called something we have never seen — and treating that\n * file as headerless would import the word \"Email Address\" as a line that is\n * not an address, and shift every subsequent column read by one.\n *\n * A one-column file of bare addresses therefore has no header, correctly:\n * every row including the first carries an address.\n */\nfunction looksLikeHeader(cells: readonly string[]): boolean {\n return !cells.some((cell) => normalizeContactEmail(cell))\n}\n\n/**\n * Reads an uploaded or pasted file into rows.\n *\n * Handles the two shapes a merchant's list actually arrives in: a CSV with a\n * header, and a bare column of addresses with no header at all. Both go\n * through one parser — `parseCsv` — because a newline-separated list of\n * addresses IS a single-column CSV, and a second code path for it would be a\n * second place for the quoting rules to differ.\n *\n * ## Every line comes back, including the bad ones\n *\n * A file that silently discarded its malformed lines would tell an operator\n * that 100 addresses went on the list when 94 did, and the six they never\n * hear about are the six they typed wrong. This is the rule `resolveAddresses`\n * already applies to a paste, restated for a file.\n */\nexport function parseListImport(text: string): ParsedListImport {\n const table = parseCsv(String(text ?? ''))\n if (!table.length) {\n return {\n columns: [],\n rows: [],\n usable: 0,\n unusable: 0,\n duplicates: 0,\n overCeiling: false,\n }\n }\n const headed = table.length > 1 && looksLikeHeader(table[0])\n const columns = headed ? table[0].map((cell) => String(cell ?? '').trim()) : []\n const body = headed ? table.slice(1) : table\n\n const emailAt = headed ? columnIndex(columns, EMAIL_COLUMNS) : 0\n const nameAt = headed ? columnIndex(columns, NAME_COLUMNS) : -1\n const sourceAt = headed ? columnIndex(columns, OPT_IN_SOURCE_COLUMNS) : -1\n const dateAt = headed ? columnIndex(columns, OPT_IN_DATE_COLUMNS) : -1\n\n const seen = new Set<string>()\n const rows: ListImportRow[] = []\n let usable = 0\n let unusable = 0\n let duplicates = 0\n let overCeiling = false\n\n for (const cells of body) {\n /*\n * The named column when the header named one, and otherwise the first\n * cell in the row that IS an address.\n *\n * The fallback is what makes an unrecognized header survivable: an\n * export whose address column is called `Primary contact e-mail (work)`\n * matches no alias, and refusing the whole file over a column name would\n * be refusing the customer's migration over our vocabulary.\n */\n const raw =\n emailAt >= 0 && emailAt < cells.length\n ? String(cells[emailAt] ?? '')\n : (cells.find((cell) => normalizeContactEmail(cell)) ?? '')\n const email = normalizeContactEmail(raw)\n const input = String(raw ?? '').trim() || cells.join(', ').trim()\n if (!email) {\n unusable += 1\n rows.push({\n input,\n email: null,\n name: '',\n declaredSource: '',\n declaredAt: '',\n })\n continue\n }\n if (seen.has(email)) {\n duplicates += 1\n continue\n }\n if (seen.size >= LIST_IMPORT_MAX_ADDRESSES) {\n overCeiling = true\n break\n }\n seen.add(email)\n usable += 1\n rows.push({\n input,\n email,\n name: nameAt >= 0 ? String(cells[nameAt] ?? '').trim() : '',\n declaredSource: sourceAt >= 0 ? String(cells[sourceAt] ?? '').trim() : '',\n declaredAt: dateAt >= 0 ? String(cells[dateAt] ?? '').trim() : '',\n })\n }\n\n return { columns, rows, usable, unusable, duplicates, overCeiling }\n}\n\n/**\n * The mechanical checks, over a parsed file.\n *\n * Cheap by construction: two substring passes and one set membership per\n * address. It is deliberately not a deliverability service — Omnisend's own\n * caveat on the one that exists is that \"list cleaning confirms\n * deliverability only and does not establish recipient consent\", and consent\n * is the only question this import actually turns on.\n */\nexport function screenListImport(\n parsed: Pick<ParsedListImport, 'columns' | 'rows'>,\n): ListImportScreening {\n const roleAccounts: string[] = []\n for (const row of parsed.rows) {\n if (!row.email) continue\n const local = row.email.slice(0, row.email.indexOf('@'))\n if (ROLE_ACCOUNT_LOCAL_PARTS.includes(local)) roleAccounts.push(row.email)\n }\n const purchaseTellColumns = parsed.columns.filter((column) => {\n const key = headerKey(column)\n return PURCHASE_TELL_COLUMNS.some((tell) => key.includes(tell))\n })\n return {\n roleAccounts,\n purchaseTellColumns,\n declaresBasis: parsed.rows.some(\n (row) => !!row.declaredSource || !!row.declaredAt,\n ),\n }\n}\n\n/**\n * The sentence recorded as the REASON behind an imported person's basis.\n *\n * One line, built from what the file actually declared, so a compliance\n * question about one address gets an answer about that address rather than\n * \"somebody ticked a box on an import once\". Empty declarations produce the\n * plain sentence rather than a sentence with holes in it.\n */\nexport function importedBasisReason(row: {\n declaredSource: string\n declaredAt: string\n}): string {\n const parts: string[] = []\n if (row.declaredSource) parts.push(`declared source: ${row.declaredSource}`)\n if (row.declaredAt) parts.push(`declared opt-in: ${row.declaredAt}`)\n return parts.length\n ? `Imported from a file, attested by the operator (${parts.join('; ')}).`\n : 'Imported from a file, attested by the operator.'\n}\n"],"names":["normalizeContactEmail","parseCsv","LIST_IMPORT_MAX_ADDRESSES","ROLE_ACCOUNT_LOCAL_PARTS","PURCHASE_TELL_COLUMNS","EMAIL_COLUMNS","NAME_COLUMNS","OPT_IN_SOURCE_COLUMNS","OPT_IN_DATE_COLUMNS","headerKey","value","String","trim","toLowerCase","replace","columnIndex","columns","aliases","keys","map","alias","at","indexOf","looksLikeHeader","cells","some","cell","parseListImport","text","table","length","rows","usable","unusable","duplicates","overCeiling","headed","body","slice","emailAt","nameAt","sourceAt","dateAt","seen","Set","raw","find","email","input","join","push","name","declaredSource","declaredAt","has","size","add","screenListImport","parsed","roleAccounts","row","local","includes","purchaseTellColumns","filter","column","key","tell","declaresBasis","importedBasisReason","parts"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/list-import.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * WHAT A MERCHANT'S CONTACT FILE SAYS ABOUT ITSELF — the pure half of the\n * list-member import's controls.\n *\n * `docs/specs/email-competitive-gaps.md` G5 is the product gap — a customer\n * arriving with a list has no way to bring it — and P4 is its condition: an\n * importer is simultaneously the biggest onboarding blocker and the biggest\n * abuse vector, so it ships WITH its controls or not at all. The import runs\n * on the platform's transfer framework (AGL-3529), which reads the file,\n * maps its columns, collapses repeated addresses and reports the lines that\n * are not addresses; this module is what the framework cannot know: the\n * header spellings that mean an address, a name or a declared opt-in, and\n * the two SCREENS. It decides nothing about consent and it enrolls nobody:\n * the consent question belongs to `enrollment-basis`, which the one-address\n * add path already asks, and an import that answered it a second way would be\n * exactly the defect class the register has a P1 entry for.\n *\n * ## The screening reports, it never refuses\n *\n * M3AAWG's Vetting BCP supplies two cheap mechanical checks and both are\n * signals rather than verdicts. A role account (`sales@`, `info@`) is, in\n * M3AAWG's words, indicative of poor acquisition practice — it is not proof\n * of one, and plenty of legitimate B2B lists carry a handful. A column named\n * `jigsaw` or `append` is a purchase tell, and again a merchant may simply\n * have named a column badly.\n *\n * So neither one drops an address. Both are put in front of the operator as\n * warnings they acknowledge BEFORE the import writes, beside the statement of\n * permission, because the attestation is the thing with teeth: an operator who\n * states they have permission for a file whose headers say it was appended\n * has made a claim their own console showed them the evidence against. That\n * is what makes the record worth keeping.\n *\n * ## A declared basis per address, and why it is still an assertion\n *\n * P4 asks for \"a declared basis per address, not one checkbox over the file\".\n * A file may carry an opt-in source and an opt-in date per row, and when it\n * does they are read and carried onto the membership as evidence. They do\n * NOT become the person's own opt-in. Nothing arriving in a spreadsheet is a\n * checkbox somebody ticked; it is the merchant telling us about one, which is\n * an operator assertion however many columns it arrives in. The basis stays\n * `operator-attested` and the declared source rides along as the reason.\n */\n\n/**\n * The most rows one uploaded file may carry — the list-member resource's\n * `limits.maxRows` in `plugins.config.json`, which the transfer engine\n * enforces at the upload (`list-import.spec.ts` holds the two equal).\n *\n * A bound on the WORK an import represents, not a capacity limit: it refuses\n * a FILE, before anything is written, and it can never drop a person already\n * on a list or trim one to make room. A merchant with more than this splits\n * the file, which is a nuisance; a merchant whose audience got silently\n * truncated has an audience they cannot reason about.\n *\n * 50,000 is Klaviyo's shape rather than a number of our own — its only\n * published hard import limit is a 50 MB CSV, which is the same statement in\n * bytes.\n */\nexport const LIST_IMPORT_MAX_ADDRESSES = 50_000\n\n/**\n * Local parts that make an address a ROLE account.\n *\n * A mailbox several people read, or none. M3AAWG's Vetting BCP: their\n * appearance on a customer list \"may be indicative of poor acquisition\n * practices\" — a list built by scraping a website collects these, a list\n * built from signups does not.\n *\n * Reported, never dropped. A merchant importing their own supplier contacts\n * legitimately has `orders@`, and an importer that quietly removed addresses\n * would be telling the operator a different number went on the list than did.\n */\nexport const ROLE_ACCOUNT_LOCAL_PARTS: readonly string[] = [\n 'abuse',\n 'admin',\n 'billing',\n 'contact',\n 'enquiries',\n 'help',\n 'hello',\n 'hr',\n 'info',\n 'inquiries',\n 'mail',\n 'marketing',\n 'noreply',\n 'no-reply',\n 'office',\n 'orders',\n 'postmaster',\n 'sales',\n 'security',\n 'staff',\n 'support',\n 'team',\n 'webmaster',\n]\n\n/**\n * Column names that say where the file came from, when the answer is \"not\n * from people who asked\".\n *\n * M3AAWG names `jigsaw` and `append` specifically. The rest are the same tell\n * in the vocabulary the data brokers actually use, and they are matched as\n * substrings of a normalized header so `Appended Email` and `append_date`\n * both hit.\n *\n * Every vendor surveyed prohibits purchased lists outright, Apple bans\n * purchased, rented and appended lists, and M3AAWG calls appending \"a direct\n * violation of core M3AAWG values\". This is not a policy we invented and the\n * screening exists so that a merchant cannot attest their way past it without\n * having been shown it.\n */\nexport const PURCHASE_TELL_COLUMNS: readonly string[] = [\n 'jigsaw',\n 'append',\n 'purchased',\n 'rented',\n 'databroker',\n 'data broker',\n 'leadgen',\n 'lead gen',\n 'scraped',\n]\n\n/** Header spellings that name the address column. */\nexport const LIST_IMPORT_EMAIL_COLUMNS: readonly string[] = [\n 'email',\n 'emailaddress',\n 'email address',\n 'e-mail',\n 'e-mail address',\n 'mail',\n 'contact email',\n 'primary email',\n]\n\n/**\n * Header spellings that name the member's display name.\n *\n * Not `First name`: a file that splits the name maps its parts to the\n * contact record's first and last name, and the membership's name is\n * composed from them when no column names it whole.\n */\nexport const LIST_IMPORT_NAME_COLUMNS: readonly string[] = [\n 'name',\n 'full name',\n 'fullname',\n 'display name',\n 'contact name',\n]\n\n/** Header spellings carrying the merchant's declared opt-in source. */\nexport const LIST_IMPORT_OPT_IN_SOURCE_COLUMNS: readonly string[] = [\n 'opt-in source',\n 'optin source',\n 'opt in source',\n 'consent source',\n 'signup source',\n 'subscription source',\n 'source',\n]\n\n/** Header spellings carrying the merchant's declared opt-in date. */\nexport const LIST_IMPORT_OPT_IN_DATE_COLUMNS: readonly string[] = [\n 'opt-in date',\n 'optin date',\n 'opt in date',\n 'consent date',\n 'signup date',\n 'subscribed at',\n 'date subscribed',\n 'confirmed at',\n]\n\n/** Normalizes a header cell for the purchase-tell match. */\nfunction headerKey(value: string): string {\n return String(value ?? '')\n .trim()\n .toLowerCase()\n .replace(/[_]+/g, ' ')\n .replace(/\\s+/g, ' ')\n}\n\n/** Whether a normalized address is at a shared or unattended mailbox. */\nexport function isRoleAccount(email: string | null | undefined): boolean {\n const address = String(email ?? '')\n const at = address.indexOf('@')\n if (at <= 0) return false\n return ROLE_ACCOUNT_LOCAL_PARTS.includes(address.slice(0, at).toLowerCase())\n}\n\n/**\n * The file's column names that read as purchase or append tells — every\n * column, mapped or not: a column the operator chose to ignore still says\n * where the file came from.\n */\nexport function purchaseTellColumns(headers: readonly string[]): string[] {\n return headers.filter((column) => {\n const key = headerKey(column)\n return PURCHASE_TELL_COLUMNS.some((tell) => key.includes(tell))\n })\n}\n\n/**\n * The sentence recorded as the REASON behind an imported person's basis.\n *\n * One line, built from what the file actually declared, so a compliance\n * question about one address gets an answer about that address rather than\n * \"somebody ticked a box on an import once\". Empty declarations produce the\n * plain sentence rather than a sentence with holes in it.\n */\nexport function importedBasisReason(row: {\n declaredSource: string\n declaredAt: string\n}): string {\n const parts: string[] = []\n if (row.declaredSource) parts.push(`declared source: ${row.declaredSource}`)\n if (row.declaredAt) parts.push(`declared opt-in: ${row.declaredAt}`)\n return parts.length\n ? `Imported from a file, attested by the operator (${parts.join('; ')}).`\n : 'Imported from a file, attested by the operator.'\n}\n"],"names":["LIST_IMPORT_MAX_ADDRESSES","ROLE_ACCOUNT_LOCAL_PARTS","PURCHASE_TELL_COLUMNS","LIST_IMPORT_EMAIL_COLUMNS","LIST_IMPORT_NAME_COLUMNS","LIST_IMPORT_OPT_IN_SOURCE_COLUMNS","LIST_IMPORT_OPT_IN_DATE_COLUMNS","headerKey","value","String","trim","toLowerCase","replace","isRoleAccount","email","address","at","indexOf","includes","slice","purchaseTellColumns","headers","filter","column","key","some","tell","importedBasisReason","row","parts","declaredSource","push","declaredAt","length","join"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0CC,GAED;;;;;;;;;;;;;;CAcC,GACD,OAAO,MAAMA,4BAA4B,MAAM;AAE/C;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,2BAA8C;IACzD;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD,CAAA;AAED;;;;;;;;;;;;;;CAcC,GACD,OAAO,MAAMC,wBAA2C;IACtD;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD,CAAA;AAED,mDAAmD,GACnD,OAAO,MAAMC,4BAA+C;IAC1D;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD,CAAA;AAED;;;;;;CAMC,GACD,OAAO,MAAMC,2BAA8C;IACzD;IACA;IACA;IACA;IACA;CACD,CAAA;AAED,qEAAqE,GACrE,OAAO,MAAMC,oCAAuD;IAClE;IACA;IACA;IACA;IACA;IACA;IACA;CACD,CAAA;AAED,mEAAmE,GACnE,OAAO,MAAMC,kCAAqD;IAChE;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD,CAAA;AAED,0DAA0D,GAC1D,SAASC,UAAUC,KAAa;IAC9B,OAAOC,OAAOD,gBAAAA,QAAS,IACpBE,IAAI,GACJC,WAAW,GACXC,OAAO,CAAC,SAAS,KACjBA,OAAO,CAAC,QAAQ;AACrB;AAEA,uEAAuE,GACvE,OAAO,SAASC,cAAcC,KAAgC;IAC5D,MAAMC,UAAUN,OAAOK,gBAAAA,QAAS;IAChC,MAAME,KAAKD,QAAQE,OAAO,CAAC;IAC3B,IAAID,MAAM,GAAG,OAAO;IACpB,OAAOf,yBAAyBiB,QAAQ,CAACH,QAAQI,KAAK,CAAC,GAAGH,IAAIL,WAAW;AAC3E;AAEA;;;;CAIC,GACD,OAAO,SAASS,oBAAoBC,OAA0B;IAC5D,OAAOA,QAAQC,MAAM,CAAC,CAACC;QACrB,MAAMC,MAAMjB,UAAUgB;QACtB,OAAOrB,sBAAsBuB,IAAI,CAAC,CAACC,OAASF,IAAIN,QAAQ,CAACQ;IAC3D;AACF;AAEA;;;;;;;CAOC,GACD,OAAO,SAASC,oBAAoBC,GAGnC;IACC,MAAMC,QAAkB,EAAE;IAC1B,IAAID,IAAIE,cAAc,EAAED,MAAME,IAAI,CAAC,CAAC,iBAAiB,EAAEH,IAAIE,cAAc,EAAE;IAC3E,IAAIF,IAAII,UAAU,EAAEH,MAAME,IAAI,CAAC,CAAC,iBAAiB,EAAEH,IAAII,UAAU,EAAE;IACnE,OAAOH,MAAMI,MAAM,GACf,CAAC,gDAAgD,EAAEJ,MAAMK,IAAI,CAAC,MAAM,EAAE,CAAC,GACvE;AACN"}
|
|
@@ -34,3 +34,27 @@ export declare function emailTopicsFromDocs(docs: ReadonlyArray<{
|
|
|
34
34
|
id: string;
|
|
35
35
|
data: () => Record<string, unknown> | undefined;
|
|
36
36
|
}>): SubscriptionTopic[];
|
|
37
|
+
/** What one topic's document says: the fields a person edits on the topic's page. */
|
|
38
|
+
export interface EmailTopicFields {
|
|
39
|
+
name: string;
|
|
40
|
+
description: string;
|
|
41
|
+
archived: boolean;
|
|
42
|
+
/** `true`/`false` decides for this stream; `null` clears it back to the site's setting; omitted leaves it. */
|
|
43
|
+
doubleOptIn?: boolean | null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The document every writer of the catalog stores — the console's topic
|
|
47
|
+
* page and list (`writeEmailTopic`) and a package import — so a topic
|
|
48
|
+
* written by either is the same document. Merged into what is stored.
|
|
49
|
+
*
|
|
50
|
+
* `archived` is written every time, because a merge that omitted it would
|
|
51
|
+
* carry an old retirement forward. `doubleOptIn` is the one field with three
|
|
52
|
+
* states, so it is written only when given, and `clear` (the SDK's
|
|
53
|
+
* `deleteField()`) is how `null` returns it to the site's setting.
|
|
54
|
+
*/
|
|
55
|
+
export declare function emailTopicDocument<Clear>(topic: EmailTopicFields, clear: Clear): {
|
|
56
|
+
name: string;
|
|
57
|
+
description: string;
|
|
58
|
+
archived: boolean;
|
|
59
|
+
doubleOptIn?: boolean | Clear;
|
|
60
|
+
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
1
2
|
/**
|
|
2
3
|
* @license
|
|
3
4
|
* Copyright 2026 Aglyn LLC
|
|
@@ -32,5 +33,23 @@
|
|
|
32
33
|
return normalizeSubscriptionTopic(doc.id, (_doc_data = doc.data()) != null ? _doc_data : null);
|
|
33
34
|
}).filter((topic)=>!!topic);
|
|
34
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* The document every writer of the catalog stores — the console's topic
|
|
38
|
+
* page and list (`writeEmailTopic`) and a package import — so a topic
|
|
39
|
+
* written by either is the same document. Merged into what is stored.
|
|
40
|
+
*
|
|
41
|
+
* `archived` is written every time, because a merge that omitted it would
|
|
42
|
+
* carry an old retirement forward. `doubleOptIn` is the one field with three
|
|
43
|
+
* states, so it is written only when given, and `clear` (the SDK's
|
|
44
|
+
* `deleteField()`) is how `null` returns it to the site's setting.
|
|
45
|
+
*/ export function emailTopicDocument(topic, clear) {
|
|
46
|
+
return _extends({
|
|
47
|
+
name: topic.name,
|
|
48
|
+
description: topic.description,
|
|
49
|
+
archived: topic.archived
|
|
50
|
+
}, topic.doubleOptIn === undefined ? {} : {
|
|
51
|
+
doubleOptIn: topic.doubleOptIn === null ? clear : topic.doubleOptIn
|
|
52
|
+
});
|
|
53
|
+
}
|
|
35
54
|
|
|
36
55
|
//# sourceMappingURL=email-topic-catalog.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/model/email-topic-catalog.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n normalizeSubscriptionTopic,\n type SubscriptionTopic,\n} from '@aglyn/aglyn/app-utils/subscription-topics'\n\n/**\n * THE TOPIC CATALOG AN ORG AUTHORS — this plugin's storage.\n *\n * The streams themselves, what a recipient's opt-out means and how a send\n * honors it are the mail rail's (`app-utils/subscription-topics.ts`), and the\n * built-in streams are declared by the plugins that send under them. What an\n * org writes — a built-in renamed, re-described, archived or confirmed, and\n * the topics it adds of its own — is stored here, one document per topic, at\n * `orgs/{orgId}/emailTopics/{topicId}`, org-shared like `lists`. Another\n * plugin that needs an org's catalog asks the `subscriptionTopic` record\n * index this plugin publishes, never the collection.\n */\n\n/** `orgs/{orgId}/emailTopics` — the catalog, org-shared like `lists`. */\nexport const EMAIL_TOPICS_COLLECTION = 'emailTopics'\n\n/** The stored documents as topics, skipping any that is not one. */\nexport function emailTopicsFromDocs(\n docs: ReadonlyArray<{ id: string; data: () => Record<string, unknown> | undefined }>,\n): SubscriptionTopic[] {\n return docs\n .map((doc) => normalizeSubscriptionTopic(doc.id, doc.data() ?? null))\n .filter((topic): topic is SubscriptionTopic => !!topic)\n}\n"],"names":["normalizeSubscriptionTopic","EMAIL_TOPICS_COLLECTION","emailTopicsFromDocs","docs","map","doc","id","data","filter","topic"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,0BAA0B,QAErB,6CAA4C;AAEnD;;;;;;;;;;;CAWC,GAED,uEAAuE,GACvE,OAAO,MAAMC,0BAA0B,cAAa;AAEpD,kEAAkE,GAClE,OAAO,SAASC,oBACdC,IAAoF;IAEpF,OAAOA,KACJC,GAAG,CAAC,CAACC;YAA2CA;eAAnCL,2BAA2BK,IAAIC,EAAE,GAAED,YAAAA,IAAIE,IAAI,cAARF,YAAc;OAC9DG,MAAM,CAAC,CAACC,QAAsC,CAAC,CAACA;AACrD"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/model/email-topic-catalog.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n normalizeSubscriptionTopic,\n type SubscriptionTopic,\n} from '@aglyn/aglyn/app-utils/subscription-topics'\n\n/**\n * THE TOPIC CATALOG AN ORG AUTHORS — this plugin's storage.\n *\n * The streams themselves, what a recipient's opt-out means and how a send\n * honors it are the mail rail's (`app-utils/subscription-topics.ts`), and the\n * built-in streams are declared by the plugins that send under them. What an\n * org writes — a built-in renamed, re-described, archived or confirmed, and\n * the topics it adds of its own — is stored here, one document per topic, at\n * `orgs/{orgId}/emailTopics/{topicId}`, org-shared like `lists`. Another\n * plugin that needs an org's catalog asks the `subscriptionTopic` record\n * index this plugin publishes, never the collection.\n */\n\n/** `orgs/{orgId}/emailTopics` — the catalog, org-shared like `lists`. */\nexport const EMAIL_TOPICS_COLLECTION = 'emailTopics'\n\n/** The stored documents as topics, skipping any that is not one. */\nexport function emailTopicsFromDocs(\n docs: ReadonlyArray<{ id: string; data: () => Record<string, unknown> | undefined }>,\n): SubscriptionTopic[] {\n return docs\n .map((doc) => normalizeSubscriptionTopic(doc.id, doc.data() ?? null))\n .filter((topic): topic is SubscriptionTopic => !!topic)\n}\n\n/** What one topic's document says: the fields a person edits on the topic's page. */\nexport interface EmailTopicFields {\n name: string\n description: string\n archived: boolean\n /** `true`/`false` decides for this stream; `null` clears it back to the site's setting; omitted leaves it. */\n doubleOptIn?: boolean | null\n}\n\n/**\n * The document every writer of the catalog stores — the console's topic\n * page and list (`writeEmailTopic`) and a package import — so a topic\n * written by either is the same document. Merged into what is stored.\n *\n * `archived` is written every time, because a merge that omitted it would\n * carry an old retirement forward. `doubleOptIn` is the one field with three\n * states, so it is written only when given, and `clear` (the SDK's\n * `deleteField()`) is how `null` returns it to the site's setting.\n */\nexport function emailTopicDocument<Clear>(\n topic: EmailTopicFields,\n clear: Clear,\n): { name: string; description: string; archived: boolean; doubleOptIn?: boolean | Clear } {\n return {\n name: topic.name,\n description: topic.description,\n archived: topic.archived,\n ...(topic.doubleOptIn === undefined\n ? {}\n : { doubleOptIn: topic.doubleOptIn === null ? clear : topic.doubleOptIn }),\n }\n}\n"],"names":["normalizeSubscriptionTopic","EMAIL_TOPICS_COLLECTION","emailTopicsFromDocs","docs","map","doc","id","data","filter","topic","emailTopicDocument","clear","name","description","archived","doubleOptIn","undefined"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,0BAA0B,QAErB,6CAA4C;AAEnD;;;;;;;;;;;CAWC,GAED,uEAAuE,GACvE,OAAO,MAAMC,0BAA0B,cAAa;AAEpD,kEAAkE,GAClE,OAAO,SAASC,oBACdC,IAAoF;IAEpF,OAAOA,KACJC,GAAG,CAAC,CAACC;YAA2CA;eAAnCL,2BAA2BK,IAAIC,EAAE,GAAED,YAAAA,IAAIE,IAAI,cAARF,YAAc;OAC9DG,MAAM,CAAC,CAACC,QAAsC,CAAC,CAACA;AACrD;AAWA;;;;;;;;;CASC,GACD,OAAO,SAASC,mBACdD,KAAuB,EACvBE,KAAY;IAEZ,OAAO;QACLC,MAAMH,MAAMG,IAAI;QAChBC,aAAaJ,MAAMI,WAAW;QAC9BC,UAAUL,MAAMK,QAAQ;OACpBL,MAAMM,WAAW,KAAKC,YACtB,CAAC,IACD;QAAED,aAAaN,MAAMM,WAAW,KAAK,OAAOJ,QAAQF,MAAMM,WAAW;IAAC;AAE9E"}
|
package/src/lib/model/index.d.ts
CHANGED
|
@@ -20,36 +20,11 @@
|
|
|
20
20
|
* other plugins/apps via `@aglyn/plugins-email/model`.
|
|
21
21
|
*/
|
|
22
22
|
export { renderEmailHtml, substituteMergeTokens, resolveMergeTags, type EmailRenderOptions, type EmailRenderProduct, type RenderedEmail, } from '@aglyn/shared-util-email';
|
|
23
|
-
/**
|
|
24
|
-
* Campaign reporting: the rate math, the populations the send recorded, and
|
|
25
|
-
* the link rollup — pure, so every denominator is named once and provable
|
|
26
|
-
* rather than chosen in JSX.
|
|
27
|
-
*/
|
|
28
|
-
export * from '@aglyn/shared-ui-email-campaigns/model/campaign-report';
|
|
29
|
-
/**
|
|
30
|
-
* The same math taken across every message sent from one TEMPLATE. Separate
|
|
31
|
-
* from `campaign-report` because a sum has a membership: which messages went
|
|
32
|
-
* into it, and therefore which population the rate describes.
|
|
33
|
-
*/
|
|
34
|
-
export * from './template-report';
|
|
35
|
-
/** Reading one message record — its state, and when it went out. */
|
|
36
|
-
export * from '@aglyn/shared-ui-email-campaigns/model/email-record';
|
|
37
23
|
/**
|
|
38
24
|
* Whose template this is: authored here, or installed from a marketplace
|
|
39
25
|
* listing and versioned by somebody else.
|
|
40
26
|
*/
|
|
41
27
|
export * from './template-provenance';
|
|
42
|
-
/**
|
|
43
|
-
* One campaign message, rendered. Shared by the send loop, the composer's
|
|
44
|
-
* pre-send preview and any other surface that has to show what will be
|
|
45
|
-
* mailed — a preview that renders the message a second way previews
|
|
46
|
-
* something else.
|
|
47
|
-
*/
|
|
48
|
-
/**
|
|
49
|
-
* The campaign CONTAINER — its window, its lists, and the arithmetic that
|
|
50
|
-
* rolls its sends into one set of figures.
|
|
51
|
-
*/
|
|
52
|
-
export * from '@aglyn/shared-ui-email-campaigns/model/campaign-container';
|
|
53
28
|
/**
|
|
54
29
|
* How each sending-domain state reads, said once for the list and the
|
|
55
30
|
* domain's own page — including why `inconclusive` is neither of them.
|