@antelopejs/dms-mailing 0.7.0 → 0.8.0
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 +28 -10
- package/dist/constants.d.ts +0 -2
- package/dist/constants.js +1 -3
- package/dist/constants.js.map +1 -1
- package/dist/crons/jobs.d.ts +1 -1
- package/dist/data/body.js +2 -1
- package/dist/data/body.js.map +1 -1
- package/dist/data/templates-table.js +5 -11
- package/dist/data/templates-table.js.map +1 -1
- package/dist/db/models/index.d.ts +0 -1
- package/dist/db/models/index.js +0 -1
- package/dist/db/models/index.js.map +1 -1
- package/dist/db/models/send-events.model.js +2 -3
- package/dist/db/models/send-events.model.js.map +1 -1
- package/dist/db/models/sends.model.js +2 -3
- package/dist/db/models/sends.model.js.map +1 -1
- package/dist/db/models/template-versions.model.js.map +1 -1
- package/dist/db/models/templates.model.js +4 -10
- package/dist/db/models/templates.model.js.map +1 -1
- package/dist/db/tables/index.d.ts +0 -1
- package/dist/db/tables/index.js +0 -1
- package/dist/db/tables/index.js.map +1 -1
- package/dist/db/tables/settings.table.d.ts +0 -1
- package/dist/db/tables/settings.table.js +0 -4
- package/dist/db/tables/settings.table.js.map +1 -1
- package/dist/db/tables/templates.table.d.ts +0 -3
- package/dist/db/tables/templates.table.js +1 -13
- package/dist/db/tables/templates.table.js.map +1 -1
- package/dist/engine/changes.js +2 -1
- package/dist/engine/changes.js.map +1 -1
- package/dist/engine/conditions.js +2 -3
- package/dist/engine/conditions.js.map +1 -1
- package/dist/engine/fallback-html.js +8 -8
- package/dist/engine/fallback-html.js.map +1 -1
- package/dist/engine/formats.js +6 -6
- package/dist/engine/formats.js.map +1 -1
- package/dist/engine/paths.js +2 -3
- package/dist/engine/paths.js.map +1 -1
- package/dist/engine/system-variables.js +1 -1
- package/dist/engine/system-variables.js.map +1 -1
- package/dist/engine/tokens.js.map +1 -1
- package/dist/hooks/index.d.ts +0 -1
- package/dist/hooks/index.js +0 -1
- package/dist/hooks/index.js.map +1 -1
- package/dist/implementations/dms-mailing/index.d.ts +3 -4
- package/dist/implementations/dms-mailing/index.js +8 -9
- package/dist/implementations/dms-mailing/index.js.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/dist/pages/sends.js.map +1 -1
- package/dist/pages/settings/form.js +8 -0
- package/dist/pages/settings/form.js.map +1 -1
- package/dist/pages/toolbar.js.map +1 -1
- package/dist/routes/events.js +2 -2
- package/dist/routes/events.js.map +1 -1
- package/dist/routes/metrics.js +6 -5
- package/dist/routes/metrics.js.map +1 -1
- package/dist/routes/sends.js +1 -1
- package/dist/routes/sends.js.map +1 -1
- package/dist/routes/settings.js +6 -6
- package/dist/routes/settings.js.map +1 -1
- package/dist/routes/templates.js +6 -10
- package/dist/routes/templates.js.map +1 -1
- package/dist/services/attention.js +2 -1
- package/dist/services/attention.js.map +1 -1
- package/dist/services/categories.js +6 -6
- package/dist/services/categories.js.map +1 -1
- package/dist/services/checked-send.js +16 -9
- package/dist/services/checked-send.js.map +1 -1
- package/dist/services/declarations.js +42 -184
- package/dist/services/declarations.js.map +1 -1
- package/dist/services/events.js +11 -7
- package/dist/services/events.js.map +1 -1
- package/dist/services/localized-text.d.ts +2 -1
- package/dist/services/localized-text.js +2 -1
- package/dist/services/localized-text.js.map +1 -1
- package/dist/services/metrics.js.map +1 -1
- package/dist/services/render.js.map +1 -1
- package/dist/services/send-import.js +7 -5
- package/dist/services/send-import.js.map +1 -1
- package/dist/services/send.js +4 -3
- package/dist/services/send.js.map +1 -1
- package/dist/services/settings.d.ts +1 -1
- package/dist/services/settings.js +1 -5
- package/dist/services/settings.js.map +1 -1
- package/dist/services/starters.js +5 -5
- package/dist/services/starters.js.map +1 -1
- package/dist/services/template-stats.js +5 -2
- package/dist/services/template-stats.js.map +1 -1
- package/dist/services/templates.d.ts +2 -2
- package/dist/services/templates.js +54 -29
- package/dist/services/templates.js.map +1 -1
- package/dist/types/settings.d.ts +2 -0
- package/dist/types/settings.js +8 -1
- package/dist/types/settings.js.map +1 -1
- package/dist/validation/declarations.schema.d.ts +16 -16
- package/dist/validation/declarations.schema.js +2 -2
- package/dist/validation/declarations.schema.js.map +1 -1
- package/dist/validation/guards.d.ts +4 -0
- package/dist/validation/guards.js +19 -0
- package/dist/validation/guards.js.map +1 -0
- package/dist/validation/send-event-type.schema.d.ts +2 -0
- package/dist/validation/send-event-type.schema.js +11 -0
- package/dist/validation/send-event-type.schema.js.map +1 -0
- package/dist/validation/settings.schema.d.ts +16 -16
- package/dist/validation/settings.schema.js +1 -1
- package/dist/validation/settings.schema.js.map +1 -1
- package/dist/validation/templates.schema.d.ts +19 -18
- package/dist/validation/templates.schema.js.map +1 -1
- package/frontend-vue/app/build/components/TemplateDrawerFooter.vue +45 -0
- package/frontend-vue/app/build/components/TemplateDrawerPerformance.vue +102 -0
- package/frontend-vue/app/build/components/TemplateDrawerVariables.vue +110 -0
- package/frontend-vue/app/build/composables/useDrawerStats.ts +78 -0
- package/frontend-vue/app/components/DuplicateModal.vue +3 -11
- package/frontend-vue/app/components/FixAddressModal.vue +2 -1
- package/frontend-vue/app/components/NewTemplateModal.vue +3 -11
- package/frontend-vue/app/components/RealSendModal.vue +6 -17
- package/frontend-vue/app/components/RenderingModal.vue +2 -1
- package/frontend-vue/app/components/SendDrawer.vue +5 -4
- package/frontend-vue/app/components/StarterGallery.vue +3 -11
- package/frontend-vue/app/components/TemplateDrawer.vue +40 -269
- package/frontend-vue/app/components/TestSendModal.vue +7 -18
- package/frontend-vue/app/components/VersionHistory.vue +2 -7
- package/frontend-vue/app/components/WebhookStatus.vue +2 -1
- package/frontend-vue/app/components/editor/EditorWorkspace.vue +2 -7
- package/frontend-vue/build/backend-boundary.ts +122 -0
- package/frontend-vue/dms.frontend.build.ts +7 -0
- package/frontend-vue/eslint.config.mjs +13 -0
- package/frontend-vue/package.json +4 -2
- package/frontend-vue/pnpm-lock.yaml +210 -61
- package/frontend-vue/tests/backend-boundary.test.ts +203 -0
- package/frontend-vue/tests/dnd.test.ts +1 -2
- package/frontend-vue/tests/editor-block-list.test.ts +136 -53
- package/frontend-vue/tests/helpers/auth-fetch.ts +123 -0
- package/frontend-vue/tests/helpers/nuxt-globals.ts +17 -1
- package/frontend-vue/tests/new-template-modal.test.ts +41 -6
- package/frontend-vue/tests/page-reads.test.ts +26 -6
- package/frontend-vue/tests/send-actions.test.ts +0 -1
- package/frontend-vue/tests/send-drawer.test.ts +2 -6
- package/frontend-vue/tests/send-modals.test.ts +8 -5
- package/frontend-vue/tests/sortable-list.test.ts +54 -62
- package/frontend-vue/tests/suppression.test.ts +1 -2
- package/frontend-vue/tests/template-drawer.test.ts +27 -18
- package/frontend-vue/tests/template-gallery.test.ts +7 -5
- package/frontend-vue/tests/template-preview-frame.test.ts +2 -5
- package/package.json +8 -13
- package/skills/dms-mailing/SKILL.md +11 -17
- package/skills/dms-mailing/cases.json +49 -0
- package/src/constants.ts +0 -2
- package/src/crons/jobs.ts +1 -1
- package/src/data/body.ts +2 -1
- package/src/data/templates-table.ts +15 -22
- package/src/db/models/index.ts +0 -1
- package/src/db/models/send-events.model.ts +2 -2
- package/src/db/models/sends.model.ts +7 -8
- package/src/db/models/template-versions.model.ts +5 -1
- package/src/db/models/templates.model.ts +9 -16
- package/src/db/tables/index.ts +0 -1
- package/src/db/tables/settings.table.ts +0 -8
- package/src/db/tables/templates.table.ts +2 -16
- package/src/engine/changes.ts +3 -2
- package/src/engine/conditions.ts +2 -4
- package/src/engine/fallback-html.ts +13 -13
- package/src/engine/formats.ts +6 -6
- package/src/engine/paths.ts +4 -3
- package/src/engine/system-variables.ts +4 -3
- package/src/engine/tokens.ts +1 -2
- package/src/hooks/index.ts +0 -1
- package/src/implementations/dms-mailing/index.ts +5 -18
- package/src/index.ts +1 -6
- package/src/pages/sends.ts +5 -3
- package/src/pages/settings/form.ts +16 -0
- package/src/pages/templates.ts +1 -1
- package/src/pages/toolbar.ts +4 -2
- package/src/routes/events.ts +3 -6
- package/src/routes/metrics.ts +7 -5
- package/src/routes/sends.ts +10 -18
- package/src/routes/settings.ts +8 -9
- package/src/routes/suppressions.ts +1 -1
- package/src/routes/templates.ts +16 -18
- package/src/services/attention.ts +4 -3
- package/src/services/categories.ts +6 -6
- package/src/services/checked-send.ts +17 -19
- package/src/services/declarations.ts +62 -318
- package/src/services/events.ts +16 -13
- package/src/services/localized-text.ts +4 -2
- package/src/services/metrics.ts +2 -2
- package/src/services/notify.ts +1 -1
- package/src/services/render.ts +5 -1
- package/src/services/send-import.ts +18 -13
- package/src/services/send.ts +7 -7
- package/src/services/settings.ts +3 -10
- package/src/services/starters.ts +6 -6
- package/src/services/template-stats.ts +11 -4
- package/src/services/templates.ts +69 -52
- package/src/types/settings.ts +20 -0
- package/src/validation/declarations.schema.ts +2 -2
- package/src/validation/guards.ts +25 -0
- package/src/validation/send-event-type.schema.ts +10 -0
- package/src/validation/settings.schema.ts +7 -2
- package/src/validation/templates.schema.ts +2 -1
- package/dist/db/models/template-slugs.model.d.ts +0 -27
- package/dist/db/models/template-slugs.model.js +0 -43
- package/dist/db/models/template-slugs.model.js.map +0 -1
- package/dist/db/tables/template-slugs.table.d.ts +0 -7
- package/dist/db/tables/template-slugs.table.js +0 -35
- package/dist/db/tables/template-slugs.table.js.map +0 -1
- package/dist/hooks/database-ready.d.ts +0 -7
- package/dist/hooks/database-ready.js +0 -26
- package/dist/hooks/database-ready.js.map +0 -1
- package/dist/services/fingerprint.d.ts +0 -1
- package/dist/services/fingerprint.js +0 -20
- package/dist/services/fingerprint.js.map +0 -1
- package/frontend-vue/tests/i18n-editor.test.ts +0 -50
- package/frontend-vue/tests/i18n.test.ts +0 -138
- package/src/db/models/template-slugs.model.ts +0 -61
- package/src/db/tables/template-slugs.table.ts +0 -29
- package/src/hooks/database-ready.ts +0 -47
- package/src/services/fingerprint.ts +0 -22
|
@@ -1,27 +1,18 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import type {
|
|
3
|
-
DeclarationOutcome,
|
|
4
|
-
LocalizedText,
|
|
5
3
|
DeclareTemplateCategoryResult,
|
|
6
4
|
DeclareTemplateResult,
|
|
7
5
|
TemplateCategoryDeclaration,
|
|
8
6
|
TemplateDeclaration,
|
|
9
7
|
} from "@antelopejs/interface-dms-mailing";
|
|
10
|
-
import {
|
|
8
|
+
import { IsDuplicateKeyError } from "@antelopejs/interface-database";
|
|
11
9
|
import { GetModel } from "@antelopejs/interface-database-decorators";
|
|
12
10
|
import {
|
|
13
11
|
type MailingTemplate,
|
|
14
|
-
SettingsModel,
|
|
15
12
|
TemplateModel,
|
|
16
|
-
TemplateSlugModel,
|
|
17
13
|
TemplateVersionModel,
|
|
18
|
-
templateSlugIdOf,
|
|
19
14
|
} from "../db";
|
|
20
|
-
import type {
|
|
21
|
-
TemplateCategory,
|
|
22
|
-
TemplateContent,
|
|
23
|
-
VariableDefinition,
|
|
24
|
-
} from "../types";
|
|
15
|
+
import type { TemplateCategory, TemplateContent } from "../types";
|
|
25
16
|
import {
|
|
26
17
|
categoryDeclarationSchema,
|
|
27
18
|
type ParsedCategoryDeclaration,
|
|
@@ -30,54 +21,18 @@ import {
|
|
|
30
21
|
} from "../validation/declarations.schema";
|
|
31
22
|
import { categoriesSchema } from "../validation/settings.schema";
|
|
32
23
|
import { DeclarationError, parseDeclared } from "./declaration-error";
|
|
33
|
-
import { fingerprintOf } from "./fingerprint";
|
|
34
24
|
import { findCategory, getSettings, saveSettings } from "./settings";
|
|
25
|
+
import { refreshTemplateAttention } from "./template-attention";
|
|
35
26
|
import {
|
|
36
|
-
assertSlugClaimed,
|
|
37
|
-
blankFields,
|
|
38
|
-
hasDraft,
|
|
39
27
|
localesWithoutSubject,
|
|
40
|
-
parseContent,
|
|
41
|
-
parseTestData,
|
|
42
|
-
parseVariables,
|
|
43
28
|
publishedVersionOf,
|
|
44
|
-
|
|
29
|
+
publishedAtCreation,
|
|
45
30
|
requireTemplate,
|
|
46
|
-
workingContent,
|
|
47
|
-
workingVariables,
|
|
48
31
|
} from "./templates";
|
|
49
32
|
|
|
50
33
|
/** Who a declaration's writes are shown as by default. */
|
|
51
34
|
const DECLARATION_AUTHOR = "system";
|
|
52
35
|
|
|
53
|
-
const LIVE_STATUS = "live";
|
|
54
|
-
const LOG_PREFIX = "[dms-mailing]";
|
|
55
|
-
const MAX_DECLARATION_ATTEMPTS = 5;
|
|
56
|
-
|
|
57
|
-
/** What a declaration fingerprints: everything a user may change after it. */
|
|
58
|
-
interface DeclaredTemplateState {
|
|
59
|
-
name: LocalizedText;
|
|
60
|
-
category: string;
|
|
61
|
-
content: TemplateContent;
|
|
62
|
-
variables: VariableDefinition[];
|
|
63
|
-
testData: Record<string, unknown>;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** A declaration ready to write: validated, defaulted and fingerprinted. */
|
|
67
|
-
interface TemplatePlan {
|
|
68
|
-
tenantId: string;
|
|
69
|
-
slug: string;
|
|
70
|
-
state: DeclaredTemplateState;
|
|
71
|
-
author: string;
|
|
72
|
-
hash: string;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* The values as the database gives them back: a `Date` in the test data is
|
|
77
|
-
* stored as its ISO string, and the fingerprint must compare alike.
|
|
78
|
-
*/
|
|
79
|
-
const asStored = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
|
|
80
|
-
|
|
81
36
|
function assertPublishable(content: TemplateContent, slug: string): void {
|
|
82
37
|
if (!Object.keys(content.locales).length)
|
|
83
38
|
throw new DeclarationError(
|
|
@@ -105,212 +60,60 @@ function assertKnownCategory(
|
|
|
105
60
|
);
|
|
106
61
|
}
|
|
107
62
|
|
|
108
|
-
function planTemplate(declaration: ParsedTemplateDeclaration): TemplatePlan {
|
|
109
|
-
const { tenantId } = declaration;
|
|
110
|
-
const { slug } = declaration;
|
|
111
|
-
const state = asStored<DeclaredTemplateState>({
|
|
112
|
-
name: declaration.name,
|
|
113
|
-
category: declaration.category,
|
|
114
|
-
content: declaration.content as TemplateContent,
|
|
115
|
-
variables: declaration.variables,
|
|
116
|
-
testData: declaration.testData,
|
|
117
|
-
});
|
|
118
|
-
assertPublishable(state.content, slug);
|
|
119
|
-
return {
|
|
120
|
-
tenantId,
|
|
121
|
-
slug,
|
|
122
|
-
state,
|
|
123
|
-
author: declaration.author ?? DECLARATION_AUTHOR,
|
|
124
|
-
hash: fingerprintOf(state),
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* The fingerprint of what the template holds now, `null` when a user took it
|
|
130
|
-
* out of the state a declaration leaves it in: live, with no draft waiting.
|
|
131
|
-
*/
|
|
132
|
-
function currentFingerprint(template: MailingTemplate): string | null {
|
|
133
|
-
if (template.status !== LIVE_STATUS || hasDraft(template)) return null;
|
|
134
|
-
return fingerprintOf({
|
|
135
|
-
name: TemplateModel.namesOf(template),
|
|
136
|
-
category: template.category ?? "",
|
|
137
|
-
content: parseContent(template),
|
|
138
|
-
variables: parseVariables(template),
|
|
139
|
-
testData: parseTestData(template),
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
|
|
143
63
|
function resultOf(
|
|
144
64
|
template: MailingTemplate,
|
|
145
|
-
outcome:
|
|
146
|
-
version = publishedVersionOf(template),
|
|
65
|
+
outcome: DeclareTemplateResult["outcome"],
|
|
147
66
|
): DeclareTemplateResult {
|
|
148
67
|
return {
|
|
149
68
|
templateId: template._id,
|
|
150
69
|
slug: template.slug,
|
|
151
70
|
outcome,
|
|
152
|
-
version,
|
|
71
|
+
version: publishedVersionOf(template),
|
|
153
72
|
};
|
|
154
73
|
}
|
|
155
74
|
|
|
156
75
|
/**
|
|
157
|
-
* Inserts the template
|
|
158
|
-
*
|
|
159
|
-
*
|
|
76
|
+
* Inserts the declared template already published, as version 1, in one
|
|
77
|
+
* write. `undefined` when a concurrent call created the slug first (the slug
|
|
78
|
+
* index is unique): that call's row is published too.
|
|
160
79
|
*/
|
|
161
80
|
async function createDeclared(
|
|
162
|
-
|
|
81
|
+
declaration: ParsedTemplateDeclaration,
|
|
163
82
|
): Promise<MailingTemplate | undefined> {
|
|
164
|
-
const { tenantId, slug
|
|
83
|
+
const { tenantId, slug } = declaration;
|
|
165
84
|
const model = GetModel(TemplateModel, tenantId);
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
const revision = randomUUID();
|
|
169
|
-
const [id] = await model.insertFields({
|
|
170
|
-
slug,
|
|
171
|
-
name: state.name,
|
|
172
|
-
category: state.category,
|
|
173
|
-
status: "draft",
|
|
174
|
-
...blankFields(state.content),
|
|
175
|
-
json_variables: JSON.stringify(state.variables),
|
|
176
|
-
json_test_data: JSON.stringify(state.testData),
|
|
177
|
-
updatedBy: author,
|
|
178
|
-
revision,
|
|
179
|
-
declarationSeedRevision: revision,
|
|
180
|
-
pendingDeclaredHash: plan.hash,
|
|
181
|
-
});
|
|
182
|
-
const templateId = id as string;
|
|
85
|
+
const author = declaration.author ?? DECLARATION_AUTHOR;
|
|
86
|
+
let template: MailingTemplate;
|
|
183
87
|
try {
|
|
184
|
-
await
|
|
88
|
+
const [id] = await model.insertFields({
|
|
89
|
+
slug,
|
|
90
|
+
name: declaration.name,
|
|
91
|
+
category: declaration.category,
|
|
92
|
+
json_test_data: JSON.stringify(declaration.testData),
|
|
93
|
+
...publishedAtCreation(
|
|
94
|
+
randomUUID(),
|
|
95
|
+
{
|
|
96
|
+
content: declaration.content,
|
|
97
|
+
variables: declaration.variables,
|
|
98
|
+
},
|
|
99
|
+
author,
|
|
100
|
+
),
|
|
101
|
+
});
|
|
102
|
+
template = await requireTemplate(model, id);
|
|
185
103
|
} catch (error) {
|
|
186
|
-
if (
|
|
104
|
+
if (IsDuplicateKeyError(error)) return undefined;
|
|
187
105
|
throw error;
|
|
188
106
|
}
|
|
189
|
-
|
|
107
|
+
await GetModel(TemplateVersionModel, tenantId).materialize(template);
|
|
108
|
+
await refreshTemplateAttention(tenantId);
|
|
109
|
+
return template;
|
|
190
110
|
}
|
|
191
111
|
|
|
192
112
|
/**
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
tenantId: string,
|
|
198
|
-
slug: string,
|
|
199
|
-
): Promise<MailingTemplate | undefined> {
|
|
200
|
-
const model = GetModel(TemplateModel, tenantId);
|
|
201
|
-
const claim = await GetModel(TemplateSlugModel, tenantId).get(
|
|
202
|
-
templateSlugIdOf(tenantId, slug),
|
|
203
|
-
);
|
|
204
|
-
const winner = claim && (await model.get(claim.templateId));
|
|
205
|
-
return winner || model.getBySlug(slug);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function isUnmodifiedSeed(template: MailingTemplate): boolean {
|
|
209
|
-
return Boolean(
|
|
210
|
-
template.pendingDeclaredHash &&
|
|
211
|
-
template.declarationSeedRevision &&
|
|
212
|
-
template.revision === template.declarationSeedRevision &&
|
|
213
|
-
template.status === "draft" &&
|
|
214
|
-
template.publishedVersion === 0,
|
|
215
|
-
);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** Finishes the stored seed, never substituting a later caller's declaration. */
|
|
219
|
-
async function finishSeed(
|
|
220
|
-
tenantId: string,
|
|
221
|
-
template: MailingTemplate,
|
|
222
|
-
): Promise<boolean> {
|
|
223
|
-
try {
|
|
224
|
-
await assertSlugClaimed(tenantId, template.slug, template._id);
|
|
225
|
-
} catch (error) {
|
|
226
|
-
const winner = await declaredTemplate(tenantId, template.slug);
|
|
227
|
-
if (winner && winner._id !== template._id) return false;
|
|
228
|
-
throw error;
|
|
229
|
-
}
|
|
230
|
-
const settings = await getSettings(tenantId);
|
|
231
|
-
assertKnownCategory(settings.categories, template.category, template.slug);
|
|
232
|
-
return Boolean(
|
|
233
|
-
await publishSnapshot(
|
|
234
|
-
tenantId,
|
|
235
|
-
template,
|
|
236
|
-
{
|
|
237
|
-
content: workingContent(template),
|
|
238
|
-
variables: workingVariables(template),
|
|
239
|
-
},
|
|
240
|
-
template.updatedBy,
|
|
241
|
-
{
|
|
242
|
-
declaredHash: template.pendingDeclaredHash,
|
|
243
|
-
pendingDeclaredHash: "",
|
|
244
|
-
declarationSeedRevision: "",
|
|
245
|
-
},
|
|
246
|
-
),
|
|
247
|
-
);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/** Why an existing template is left alone, `null` when it may be refreshed. */
|
|
251
|
-
function keepReason(template: MailingTemplate): string | null {
|
|
252
|
-
if (!template.declaredHash) return "it was created in the dashboard";
|
|
253
|
-
if (currentFingerprint(template) !== template.declaredHash)
|
|
254
|
-
return "a user changed it since it was declared";
|
|
255
|
-
return null;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
async function applyToExisting(
|
|
259
|
-
plan: TemplatePlan,
|
|
260
|
-
template: MailingTemplate,
|
|
261
|
-
): Promise<DeclareTemplateResult> {
|
|
262
|
-
const { tenantId, state, author } = plan;
|
|
263
|
-
for (let attempt = 0; attempt < MAX_DECLARATION_ATTEMPTS; attempt++) {
|
|
264
|
-
if (isUnmodifiedSeed(template)) {
|
|
265
|
-
const created = await finishSeed(tenantId, template);
|
|
266
|
-
if (created && template.pendingDeclaredHash === plan.hash)
|
|
267
|
-
return resultOf(template, "created", 1);
|
|
268
|
-
} else {
|
|
269
|
-
await GetModel(TemplateVersionModel, tenantId).materialize(template);
|
|
270
|
-
if (template.publishedVersion > 0 && template.declaredHash === plan.hash)
|
|
271
|
-
return resultOf(template, "unchanged");
|
|
272
|
-
const reason = keepReason(template);
|
|
273
|
-
if (reason) {
|
|
274
|
-
Logging.Info(
|
|
275
|
-
`${LOG_PREFIX} Kept template "${plan.slug}" as it is: ${reason}`,
|
|
276
|
-
);
|
|
277
|
-
return resultOf(template, "kept");
|
|
278
|
-
}
|
|
279
|
-
const settings = await getSettings(tenantId);
|
|
280
|
-
assertKnownCategory(settings.categories, state.category, plan.slug);
|
|
281
|
-
const outcome = await publishSnapshot(tenantId, template, state, author, {
|
|
282
|
-
name: state.name,
|
|
283
|
-
category: state.category,
|
|
284
|
-
json_test_data: JSON.stringify(state.testData),
|
|
285
|
-
declaredHash: plan.hash,
|
|
286
|
-
});
|
|
287
|
-
if (outcome) return resultOf(template, "updated", outcome.version);
|
|
288
|
-
}
|
|
289
|
-
const current = await declaredTemplate(tenantId, plan.slug);
|
|
290
|
-
if (!current) break;
|
|
291
|
-
template = current;
|
|
292
|
-
}
|
|
293
|
-
throw new DeclarationError(
|
|
294
|
-
"declaration_conflict",
|
|
295
|
-
`Template "${plan.slug}" changed repeatedly; retry the declaration`,
|
|
296
|
-
);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/**
|
|
300
|
-
* Creates or refreshes the template a project declares from code. The rule,
|
|
301
|
-
* keyed on the slug:
|
|
302
|
-
*
|
|
303
|
-
* - no template has the slug: it is created and published (`created`);
|
|
304
|
-
* - the declaration is the one last applied: nothing is written
|
|
305
|
-
* (`unchanged`), whatever a user did to the template since;
|
|
306
|
-
* - the declaration changed and the template is still live, with no draft,
|
|
307
|
-
* and holds exactly what the last declaration wrote (name, category,
|
|
308
|
-
* content, variables, test data): the new declaration is published
|
|
309
|
-
* (`updated`);
|
|
310
|
-
* - otherwise (edited, unpublished, archived, a draft pending, or made in the
|
|
311
|
-
* dashboard): it is left as it is (`kept`).
|
|
312
|
-
*
|
|
313
|
-
* A declared template a user deleted is created again by the next call.
|
|
113
|
+
* Writes a template a project ships as code, once: call it when the tenant
|
|
114
|
+
* is provisioned. A new slug is created and published (`created`). A slug the
|
|
115
|
+
* tenant already has is left as it is (`kept`): from its creation on, the
|
|
116
|
+
* template belongs to the tenant, and a later declaration never changes it.
|
|
314
117
|
*/
|
|
315
118
|
export async function declareTemplate(
|
|
316
119
|
input: TemplateDeclaration,
|
|
@@ -321,23 +124,18 @@ export async function declareTemplate(
|
|
|
321
124
|
"invalid_template",
|
|
322
125
|
`template declaration "${String(input?.slug)}"`,
|
|
323
126
|
);
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
if (
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
)
|
|
333
|
-
return
|
|
127
|
+
const { tenantId, slug } = declaration;
|
|
128
|
+
assertPublishable(declaration.content, slug);
|
|
129
|
+
const model = GetModel(TemplateModel, tenantId);
|
|
130
|
+
const existing = await model.getBySlug(slug);
|
|
131
|
+
if (existing) return resultOf(existing, "kept");
|
|
132
|
+
const settings = await getSettings(tenantId);
|
|
133
|
+
assertKnownCategory(settings.categories, declaration.category, slug);
|
|
134
|
+
const created = await createDeclared(declaration);
|
|
135
|
+
if (created) return resultOf(created, "created");
|
|
136
|
+
return resultOf((await model.getBySlug(slug)) as MailingTemplate, "kept");
|
|
334
137
|
}
|
|
335
138
|
|
|
336
|
-
type DeclaredCategories = Record<string, string>;
|
|
337
|
-
|
|
338
|
-
const categoryFingerprint = (category: Omit<TemplateCategory, "id">) =>
|
|
339
|
-
fingerprintOf({ label: category.label, icon: category.icon });
|
|
340
|
-
|
|
341
139
|
/** Category declarations of one tenant run one after the other. */
|
|
342
140
|
const categoryQueues = new Map<string, Promise<unknown>>();
|
|
343
141
|
|
|
@@ -359,84 +157,30 @@ function inTenantQueue<T>(
|
|
|
359
157
|
return next;
|
|
360
158
|
}
|
|
361
159
|
|
|
362
|
-
async function readDeclaredCategories(
|
|
363
|
-
tenantId: string,
|
|
364
|
-
): Promise<DeclaredCategories> {
|
|
365
|
-
const row = await GetModel(SettingsModel, tenantId).get(tenantId);
|
|
366
|
-
return JSON.parse(
|
|
367
|
-
row?.json_declared_categories || "{}",
|
|
368
|
-
) as DeclaredCategories;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/** The category list once the declared category is added or relabelled. */
|
|
372
|
-
function withCategory(
|
|
373
|
-
categories: TemplateCategory[],
|
|
374
|
-
declared: TemplateCategory,
|
|
375
|
-
): TemplateCategory[] {
|
|
376
|
-
const isPresent = Boolean(findCategory(categories, declared.id));
|
|
377
|
-
const next = isPresent
|
|
378
|
-
? categories.map((category) =>
|
|
379
|
-
category.id === declared.id ? declared : category,
|
|
380
|
-
)
|
|
381
|
-
: [...categories, declared];
|
|
382
|
-
return parseDeclared(
|
|
383
|
-
categoriesSchema,
|
|
384
|
-
next,
|
|
385
|
-
"invalid_category",
|
|
386
|
-
`category declaration "${declared.id}"`,
|
|
387
|
-
).map(({ id, label, icon }) => ({ id, label, icon }));
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/** What a category declaration does to the stored list, by its state. */
|
|
391
|
-
function categoryOutcome(
|
|
392
|
-
current: TemplateCategory | undefined,
|
|
393
|
-
previousHash: string | undefined,
|
|
394
|
-
hash: string,
|
|
395
|
-
): DeclarationOutcome {
|
|
396
|
-
if (!current) return previousHash ? "kept" : "created";
|
|
397
|
-
if (!previousHash) return "kept";
|
|
398
|
-
if (previousHash === hash) return "unchanged";
|
|
399
|
-
return categoryFingerprint(current) === previousHash ? "updated" : "kept";
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
const WRITING_OUTCOMES = new Set<DeclarationOutcome>(["created", "updated"]);
|
|
403
|
-
|
|
404
160
|
async function applyCategory(
|
|
405
161
|
declaration: ParsedCategoryDeclaration,
|
|
406
162
|
): Promise<DeclareTemplateCategoryResult> {
|
|
407
|
-
const { tenantId } = declaration;
|
|
408
|
-
const category = {
|
|
409
|
-
id: declaration.id,
|
|
410
|
-
label: declaration.label,
|
|
411
|
-
icon: declaration.icon,
|
|
412
|
-
};
|
|
413
|
-
const hash = categoryFingerprint(category);
|
|
163
|
+
const { tenantId, id, label, icon } = declaration;
|
|
414
164
|
const settings = await getSettings(tenantId);
|
|
415
|
-
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
165
|
+
if (findCategory(settings.categories, id)) return { id, outcome: "kept" };
|
|
166
|
+
const categories = parseDeclared(
|
|
167
|
+
categoriesSchema,
|
|
168
|
+
[...settings.categories, { id, label, icon }],
|
|
169
|
+
"invalid_category",
|
|
170
|
+
`category declaration "${id}"`,
|
|
171
|
+
).map((category) => ({
|
|
172
|
+
id: category.id,
|
|
173
|
+
label: category.label,
|
|
174
|
+
icon: category.icon,
|
|
175
|
+
}));
|
|
423
176
|
await saveSettings(tenantId, { ...settings, categories });
|
|
424
|
-
|
|
425
|
-
json_declared_categories: JSON.stringify({
|
|
426
|
-
...declared,
|
|
427
|
-
[category.id]: hash,
|
|
428
|
-
}),
|
|
429
|
-
});
|
|
430
|
-
return { id: category.id, outcome };
|
|
177
|
+
return { id, outcome: "created" };
|
|
431
178
|
}
|
|
432
179
|
|
|
433
180
|
/**
|
|
434
|
-
* Adds
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
* as last declared (`updated`); a category a user relabelled or removed
|
|
438
|
-
* since, or one that existed before any declaration (the defaults among
|
|
439
|
-
* them), is left as it is (`kept`).
|
|
181
|
+
* Adds a template category a project ships as code, once: call it when the
|
|
182
|
+
* tenant is provisioned. A new id is added (`created`); an id the workspace
|
|
183
|
+
* settings already hold, the defaults among them, is left as it is (`kept`).
|
|
440
184
|
*/
|
|
441
185
|
export function declareTemplateCategory(
|
|
442
186
|
input: TemplateCategoryDeclaration,
|
package/src/services/events.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { publishSendUpdated } from "../realtime";
|
|
|
16
16
|
import type { SendEventType, SendStatus } from "../types";
|
|
17
17
|
import { notifyProblems } from "./notify";
|
|
18
18
|
import { suppressAddress, suppressionReasonOf } from "./suppressions";
|
|
19
|
+
import { isObject, isText } from "../validation/guards";
|
|
19
20
|
|
|
20
21
|
export interface SendCounters {
|
|
21
22
|
status: SendStatus;
|
|
@@ -59,8 +60,11 @@ const HARD_BOUNCE_TYPE = /hard|permanent/i;
|
|
|
59
60
|
/** A temporary SMTP answer: a `4xx` reply code or a `4.x.x` enhanced status. */
|
|
60
61
|
const SOFT_SMTP_ANSWER = /^\s*4\d\d\b|\b4\.\d{1,3}\.\d{1,3}\b/;
|
|
61
62
|
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
/** The text an event's details hold under `key`, else an empty string. */
|
|
64
|
+
function textOf(details: EmailEvent["details"], key: string): string {
|
|
65
|
+
const value = details?.[key];
|
|
66
|
+
return isText(value) ? value : "";
|
|
67
|
+
}
|
|
64
68
|
|
|
65
69
|
/**
|
|
66
70
|
* The provider's own words for a problem event (`550 5.1.1 Mailbox does not
|
|
@@ -70,7 +74,7 @@ const textOf = (value: unknown): string =>
|
|
|
70
74
|
export function problemReason(event: EmailEvent): string | undefined {
|
|
71
75
|
if (!PROBLEM_EVENTS.has(event.type)) return undefined;
|
|
72
76
|
const reason = event.details?.reason;
|
|
73
|
-
return
|
|
77
|
+
return isText(reason) && reason ? reason : undefined;
|
|
74
78
|
}
|
|
75
79
|
|
|
76
80
|
function overridesStatus(current: SendStatus, next: SendStatus): boolean {
|
|
@@ -95,10 +99,10 @@ export function applyEvent(
|
|
|
95
99
|
* provider's `bounceType` when it gives one, else the SMTP answer's class.
|
|
96
100
|
*/
|
|
97
101
|
export function isSoftBounce(event: EmailEvent): boolean {
|
|
98
|
-
const bounceType = textOf(event.details
|
|
102
|
+
const bounceType = textOf(event.details, "bounceType");
|
|
99
103
|
if (SOFT_BOUNCE_TYPE.test(bounceType)) return true;
|
|
100
104
|
if (HARD_BOUNCE_TYPE.test(bounceType)) return false;
|
|
101
|
-
return SOFT_SMTP_ANSWER.test(textOf(event.details
|
|
105
|
+
return SOFT_SMTP_ANSWER.test(textOf(event.details, "reason"));
|
|
102
106
|
}
|
|
103
107
|
|
|
104
108
|
/**
|
|
@@ -144,8 +148,8 @@ async function publishEmailEvent(
|
|
|
144
148
|
});
|
|
145
149
|
}
|
|
146
150
|
|
|
147
|
-
const eventIdOf = (details:
|
|
148
|
-
textOf(details
|
|
151
|
+
const eventIdOf = (details: EmailEvent["details"]): string =>
|
|
152
|
+
textOf(details, "eventId");
|
|
149
153
|
|
|
150
154
|
type StoredEventIdentity = Pick<
|
|
151
155
|
MailingSendEvent,
|
|
@@ -163,11 +167,10 @@ export function isSameEvent(
|
|
|
163
167
|
): boolean {
|
|
164
168
|
const eventId = eventIdOf(event.details);
|
|
165
169
|
if (eventId) {
|
|
166
|
-
const storedDetails = JSON.parse(stored.json_details || "{}")
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
return eventIdOf(storedDetails) === eventId;
|
|
170
|
+
const storedDetails: unknown = JSON.parse(stored.json_details || "{}");
|
|
171
|
+
return (
|
|
172
|
+
eventIdOf(isObject(storedDetails) ? storedDetails : undefined) === eventId
|
|
173
|
+
);
|
|
171
174
|
}
|
|
172
175
|
if (!event.at || stored.type !== event.type) return false;
|
|
173
176
|
return (
|
|
@@ -223,7 +226,7 @@ async function suppressAfterEvent(
|
|
|
223
226
|
email: send.recipientEmail,
|
|
224
227
|
reason,
|
|
225
228
|
sendId: send._id,
|
|
226
|
-
detail: textOf(event.details
|
|
229
|
+
detail: textOf(event.details, "reason") || undefined,
|
|
227
230
|
});
|
|
228
231
|
} catch (error) {
|
|
229
232
|
Logging.Error("[dms-mailing] Could not suppress an address:", error);
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import type { IncomingHttpHeaders } from "node:http";
|
|
1
2
|
import type { RequestContext } from "@antelopejs/interface-api";
|
|
2
3
|
import {
|
|
3
4
|
LOCALIZED_TEXT_FALLBACK_LOCALE,
|
|
4
5
|
type LocalizedText,
|
|
5
6
|
} from "@antelopejs/interface-dms-mailing";
|
|
7
|
+
import { isText } from "../validation/guards";
|
|
6
8
|
|
|
7
9
|
/** Header the DMS frontend states the reader's language in on every request. */
|
|
8
10
|
export const CONTENT_LANGUAGE_HEADER = "x-content-language";
|
|
@@ -13,8 +15,8 @@ export const CONTENT_LANGUAGE_HEADER = "x-content-language";
|
|
|
13
15
|
*
|
|
14
16
|
* @param header Value of {@link CONTENT_LANGUAGE_HEADER}
|
|
15
17
|
*/
|
|
16
|
-
export function requestLocale(header:
|
|
17
|
-
return
|
|
18
|
+
export function requestLocale(header: IncomingHttpHeaders[string]): string {
|
|
19
|
+
return isText(header) && header.length > 0
|
|
18
20
|
? header
|
|
19
21
|
: LOCALIZED_TEXT_FALLBACK_LOCALE;
|
|
20
22
|
}
|
package/src/services/metrics.ts
CHANGED
|
@@ -122,7 +122,7 @@ const wasOpened = (send: SendSummary): boolean =>
|
|
|
122
122
|
function median(values: number[]): number {
|
|
123
123
|
if (!values.length) return 0;
|
|
124
124
|
const sorted = [...values].sort((left, right) => left - right);
|
|
125
|
-
return sorted[Math.floor(sorted.length / 2)]
|
|
125
|
+
return sorted[Math.floor(sorted.length / 2)];
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
/** A send to a suppressed address never left: no figure counts it. */
|
|
@@ -262,7 +262,7 @@ export function alignSeries(
|
|
|
262
262
|
axis: SeriesPoint[],
|
|
263
263
|
): SeriesPoint[] {
|
|
264
264
|
return points.slice(0, axis.length).map((point, index) => ({
|
|
265
|
-
x:
|
|
265
|
+
x: axis[index].x,
|
|
266
266
|
y: point.y,
|
|
267
267
|
}));
|
|
268
268
|
}
|
package/src/services/notify.ts
CHANGED
|
@@ -38,7 +38,7 @@ const PERCENT = 100;
|
|
|
38
38
|
const SENDS_PAGE = "/modules/mailing/sends";
|
|
39
39
|
const PROBLEMS_VIEW = "problems";
|
|
40
40
|
const IDEMPOTENCY_KEY_SEPARATOR = ":";
|
|
41
|
-
|
|
41
|
+
/** The DMS keys notifications across every module: ours carry the module's name. */
|
|
42
42
|
const IDEMPOTENCY_KEY_PREFIX = "mailing:problems";
|
|
43
43
|
const PROBLEMS_ICON = "i-ph-warning";
|
|
44
44
|
const PROBLEMS_TITLE = "$dms_mailing.notifications.problems_title";
|
package/src/services/render.ts
CHANGED
|
@@ -17,13 +17,17 @@ export interface MailingEmailProps {
|
|
|
17
17
|
const MailingEmailTemplate =
|
|
18
18
|
RegisterHtmlTemplate<MailingEmailProps>(EMAIL_TEMPLATE_NAME);
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* The HTML of `email`: through the DMS renderer, which already drops Vue's
|
|
22
|
+
* SSR markers (dms-frontend 0.5.4), else the module's fallback HTML when the
|
|
23
|
+
* configuration turns HTML rendering off or the renderer fails.
|
|
24
|
+
*/
|
|
20
25
|
export async function renderEmailHtml(
|
|
21
26
|
email: ResolvedEmail,
|
|
22
27
|
locale: string,
|
|
23
28
|
): Promise<string> {
|
|
24
29
|
if (!getConfig().useHtmlRender) return renderFallbackHtml(email, locale);
|
|
25
30
|
try {
|
|
26
|
-
// The DMS renderer already drops Vue's SSR markers (dms-frontend 0.5.4).
|
|
27
31
|
return await GenerateHtml(MailingEmailTemplate, { locale, email });
|
|
28
32
|
} catch (error) {
|
|
29
33
|
Logging.Warn(
|