@antelopejs/dms-mailing 0.3.0 → 0.3.2
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 +3 -1
- package/dist/antelope-modules.d.ts +1 -1
- package/dist/antelope-modules.js +1 -1
- package/dist/crons/retention.js +1 -1
- package/dist/crons/retention.js.map +1 -1
- package/dist/data/templates-table.d.ts +1 -1
- package/dist/data/templates-table.js +21 -0
- package/dist/data/templates-table.js.map +1 -1
- package/dist/db/models/sends.model.d.ts +2 -1
- package/dist/db/models/sends.model.js +1 -0
- package/dist/db/models/sends.model.js.map +1 -1
- package/dist/db/models/settings.model.d.ts +1 -1
- package/dist/db/models/settings.model.js +4 -1
- package/dist/db/models/settings.model.js.map +1 -1
- package/dist/db/tables/send-events.table.js +0 -1
- package/dist/db/tables/send-events.table.js.map +1 -1
- package/dist/db/tables/sends.table.js +0 -1
- package/dist/db/tables/sends.table.js.map +1 -1
- package/dist/db/tables/templates.table.js +0 -1
- package/dist/db/tables/templates.table.js.map +1 -1
- package/dist/hooks/tenant-export.js +1 -1
- package/dist/hooks/tenant-export.js.map +1 -1
- package/dist/routes/events.js +2 -2
- package/dist/routes/events.js.map +1 -1
- package/dist/routes/period.js +7 -4
- package/dist/routes/period.js.map +1 -1
- package/dist/services/attention.d.ts +3 -3
- package/dist/services/events.d.ts +1 -1
- package/dist/services/events.js.map +1 -1
- package/dist/services/metrics.d.ts +4 -4
- package/dist/services/settings.d.ts +1 -0
- package/dist/services/settings.js +25 -8
- package/dist/services/settings.js.map +1 -1
- package/dist/test/integration/events.test.js +11 -0
- package/dist/test/integration/events.test.js.map +1 -1
- package/dist/test/integration/sends.test.js +53 -0
- package/dist/test/integration/sends.test.js.map +1 -1
- package/dist/test/integration/settings.test.js +92 -0
- package/dist/test/integration/settings.test.js.map +1 -1
- package/dist/test/integration/templates.test.js +68 -2
- package/dist/test/integration/templates.test.js.map +1 -1
- package/dist/test/unit/period.test.js +17 -0
- package/dist/test/unit/period.test.js.map +1 -1
- package/dist/test/unit/webhook-secret.test.js +1 -0
- package/dist/test/unit/webhook-secret.test.js.map +1 -1
- package/frontend-vue/i18n/locales/mailing-en-GB.json +1 -0
- package/frontend-vue/i18n/locales/mailing-fr-FR.json +1 -0
- package/frontend-vue/package.json +1 -1
- package/frontend-vue/pnpm-lock.yaml +2 -28
- package/package.json +2 -2
- package/src/antelope-modules.ts +1 -1
- package/src/crons/retention.ts +3 -1
- package/src/data/templates-table.ts +46 -0
- package/src/db/models/sends.model.ts +6 -2
- package/src/db/models/settings.model.ts +7 -1
- package/src/db/tables/send-events.table.ts +0 -1
- package/src/db/tables/sends.table.ts +0 -1
- package/src/db/tables/templates.table.ts +0 -1
- package/src/hooks/tenant-export.ts +1 -1
- package/src/routes/events.ts +3 -3
- package/src/routes/metrics.ts +3 -3
- package/src/routes/period.ts +9 -4
- package/src/services/attention.ts +5 -5
- package/src/services/events.ts +4 -4
- package/src/services/metrics.ts +9 -9
- package/src/services/settings.ts +36 -7
- package/src/test/integration/events.test.ts +24 -1
- package/src/test/integration/sends.test.ts +33 -0
- package/src/test/integration/settings.test.ts +78 -0
- package/src/test/integration/templates.test.ts +49 -6
- package/src/test/unit/period.test.ts +27 -0
- package/src/test/unit/webhook-secret.test.ts +1 -0
|
@@ -39,6 +39,7 @@ import { createTemplateSchema } from "../validation/templates.schema";
|
|
|
39
39
|
import { parseBody } from "./body";
|
|
40
40
|
|
|
41
41
|
const DUPLICATE_SLUG = "$dms_mailing.errors.duplicate_slug";
|
|
42
|
+
const SLUG_IMMUTABLE = "$dms_mailing.errors.slug_immutable";
|
|
42
43
|
const SOURCE_NOT_FOUND = "$dms_mailing.errors.template_not_found";
|
|
43
44
|
const CREATE_ONLY = { edit: ReadonlyBehaviorType.disabled };
|
|
44
45
|
// Read-only columns render as empty dashes on the create form; the four fields
|
|
@@ -99,9 +100,54 @@ function withSeeding(base: DataControllerCallback): DataControllerCallback {
|
|
|
99
100
|
};
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
interface EditParams {
|
|
104
|
+
id: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The body an edit may apply. Sends resolve templates by slug and nothing
|
|
109
|
+
* enforces its uniqueness but the create checks, so a slug never changes once
|
|
110
|
+
* set: a different one is refused, a missing one (which the data-api edit
|
|
111
|
+
* would null) is kept.
|
|
112
|
+
*/
|
|
113
|
+
async function withStoredSlug(
|
|
114
|
+
tenantId: string,
|
|
115
|
+
templateId: string,
|
|
116
|
+
body: Buffer | string,
|
|
117
|
+
): Promise<Buffer | string> {
|
|
118
|
+
const stored = await GetModel(TemplateModel, tenantId).get(templateId);
|
|
119
|
+
if (!stored) return body;
|
|
120
|
+
const edit = parseBody(body);
|
|
121
|
+
if (edit.slug !== undefined && edit.slug !== stored.slug) {
|
|
122
|
+
throw new HTTPResult(HTTP_CONFLICT, SLUG_IMMUTABLE);
|
|
123
|
+
}
|
|
124
|
+
return JSON.stringify({ ...edit, slug: stored.slug });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function withFixedSlug(base: DataControllerCallback): DataControllerCallback {
|
|
128
|
+
return {
|
|
129
|
+
...base,
|
|
130
|
+
func: async function (
|
|
131
|
+
this: unknown,
|
|
132
|
+
ctx: RequestContext,
|
|
133
|
+
params: EditParams,
|
|
134
|
+
body: Buffer | string,
|
|
135
|
+
...rest: unknown[]
|
|
136
|
+
) {
|
|
137
|
+
const guarded = await withStoredSlug(
|
|
138
|
+
getRequestTenantId(ctx),
|
|
139
|
+
params.id,
|
|
140
|
+
body,
|
|
141
|
+
);
|
|
142
|
+
return base.func.call(this, ctx, params, guarded, ...rest);
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
102
147
|
const templateRoutes = {
|
|
103
148
|
...TableViewRoutes.All,
|
|
104
149
|
new: withSeeding(TableViewRoutes.New),
|
|
150
|
+
edit: withFixedSlug(TableViewRoutes.Edit),
|
|
105
151
|
};
|
|
106
152
|
|
|
107
153
|
@RegisterDataController()
|
|
@@ -14,6 +14,9 @@ type NewSend = Omit<
|
|
|
14
14
|
"_id" | "createdAt" | "revision" | "isRetiring" | "lastActivityAt"
|
|
15
15
|
>;
|
|
16
16
|
|
|
17
|
+
/** A send as the metrics read it: everything but the rendered variables. */
|
|
18
|
+
export type SendSummary = Omit<MailingSend, "json_variables">;
|
|
19
|
+
|
|
17
20
|
type SendActivity = Pick<
|
|
18
21
|
MailingSend,
|
|
19
22
|
"status" | "opens" | "clicks" | "lastEventAt" | "provider"
|
|
@@ -74,11 +77,12 @@ export class SendModel extends BasicDataModel(
|
|
|
74
77
|
return rows[0];
|
|
75
78
|
}
|
|
76
79
|
|
|
77
|
-
listBetween(from: Date, to: Date): Promise<
|
|
80
|
+
listBetween(from: Date, to: Date): Promise<SendSummary[]> {
|
|
78
81
|
return this.table
|
|
79
82
|
.filter((row) => row.key("createdAt").ge(from))
|
|
80
83
|
.filter((row) => row.key("createdAt").lt(to))
|
|
81
|
-
.
|
|
84
|
+
.without("json_variables")
|
|
85
|
+
.run() as Promise<SendSummary[]>;
|
|
82
86
|
}
|
|
83
87
|
|
|
84
88
|
listOlderThan(limit: Date, pageSize: number): Promise<MailingSend[]> {
|
|
@@ -5,7 +5,13 @@ export class SettingsModel extends BasicDataModel(
|
|
|
5
5
|
MailingSettings,
|
|
6
6
|
MAILING_SETTINGS_TABLE_NAME,
|
|
7
7
|
) {
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* The tenant's settings row, keyed by the tenant id. Rows written before the
|
|
10
|
+
* key existed carry a random id and are still found by a scan.
|
|
11
|
+
*/
|
|
12
|
+
async getSingleton(tenantId: string): Promise<MailingSettings | undefined> {
|
|
13
|
+
const keyed = await this.get(tenantId);
|
|
14
|
+
if (keyed) return keyed;
|
|
9
15
|
const rows = await this.getAll();
|
|
10
16
|
return rows[0];
|
|
11
17
|
}
|
|
@@ -64,7 +64,7 @@ export async function* jsonArrayChunks<T>(
|
|
|
64
64
|
async function exportedSettings(
|
|
65
65
|
tenantId: string,
|
|
66
66
|
): Promise<ExportedSettings | null> {
|
|
67
|
-
const row = await GetModel(SettingsModel, tenantId).getSingleton();
|
|
67
|
+
const row = await GetModel(SettingsModel, tenantId).getSingleton(tenantId);
|
|
68
68
|
if (!row) return null;
|
|
69
69
|
return {
|
|
70
70
|
fallbackLocale: row.fallbackLocale,
|
package/src/routes/events.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { getRequestTenantId } from "@antelopejs/interface-dms/request-tenant";
|
|
|
11
11
|
import { z } from "zod";
|
|
12
12
|
import { API_BASE_PATH, HTTP_FORBIDDEN } from "../constants";
|
|
13
13
|
import { recordEmailEvent, webhookSecretMatches } from "../services/events";
|
|
14
|
-
import {
|
|
14
|
+
import { findSettings } from "../services/settings";
|
|
15
15
|
import { SEND_STATUSES, type SendStatus } from "../types";
|
|
16
16
|
|
|
17
17
|
const SECRET_HEADER = "x-mailing-webhook-secret";
|
|
@@ -39,9 +39,9 @@ export class MailingEventsController extends Controller(
|
|
|
39
39
|
) {
|
|
40
40
|
const event = assertValidation(body, (value) => eventSchema.parse(value));
|
|
41
41
|
const tenantId = event.tenantId ?? getRequestTenantId(this.ctx);
|
|
42
|
-
const settings = await
|
|
42
|
+
const settings = await findSettings(tenantId);
|
|
43
43
|
assert(
|
|
44
|
-
webhookSecretMatches(secret, settings
|
|
44
|
+
webhookSecretMatches(secret, settings?.webhookSecret),
|
|
45
45
|
HTTP_FORBIDDEN,
|
|
46
46
|
BAD_SECRET,
|
|
47
47
|
);
|
package/src/routes/metrics.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type { TopListItem } from "@antelopejs/interface-dms/base";
|
|
|
12
12
|
import type { User } from "@antelopejs/interface-dms/auth/db";
|
|
13
13
|
import { API_BASE_PATH, HTTP_NOT_FOUND } from "../constants";
|
|
14
14
|
import {
|
|
15
|
-
type
|
|
15
|
+
type SendSummary,
|
|
16
16
|
type MailingTemplate,
|
|
17
17
|
SendModel,
|
|
18
18
|
TemplateModel,
|
|
@@ -91,7 +91,7 @@ export class MetricsController extends Controller(`${API_BASE_PATH}/metrics`) {
|
|
|
91
91
|
return readAudience(this.ctx.url.searchParams.get(SEND_AUDIENCE_QUERY_KEY));
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
private async load(range: DateRange): Promise<
|
|
94
|
+
private async load(range: DateRange): Promise<SendSummary[]> {
|
|
95
95
|
const rows = await this.sends.listBetween(range.from, range.to);
|
|
96
96
|
return forAudience(rows, this.audience);
|
|
97
97
|
}
|
|
@@ -228,7 +228,7 @@ async function readTracking(): Promise<ProviderTracking | null> {
|
|
|
228
228
|
|
|
229
229
|
function toTemplateItem(
|
|
230
230
|
slug: string,
|
|
231
|
-
rows:
|
|
231
|
+
rows: SendSummary[],
|
|
232
232
|
templates: MailingTemplate[],
|
|
233
233
|
extract: (totals: Totals) => number,
|
|
234
234
|
): TopListItem {
|
package/src/routes/period.ts
CHANGED
|
@@ -21,14 +21,19 @@ function parseDate(raw: string | undefined): Date | undefined {
|
|
|
21
21
|
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** Every window is read from the send log in full, so none may exceed the cap. */
|
|
25
|
+
function clampToMaxWindow(requested: Date, to: Date): DateRange {
|
|
26
|
+
const earliest = to.getTime() - MAX_WINDOW_DAYS * DAY_MS;
|
|
27
|
+
const from = requested.getTime() < earliest ? new Date(earliest) : requested;
|
|
28
|
+
return { from, to };
|
|
29
|
+
}
|
|
30
|
+
|
|
24
31
|
export function currentRange(ctx: RangeContext): DateRange {
|
|
25
32
|
const to = parseDate(readQuery(ctx, "to")) ?? new Date();
|
|
26
33
|
const requested =
|
|
27
34
|
parseDate(readQuery(ctx, "from")) ??
|
|
28
35
|
new Date(to.getTime() - DEFAULT_WINDOW_DAYS * DAY_MS);
|
|
29
|
-
|
|
30
|
-
const from = requested.getTime() < earliest ? new Date(earliest) : requested;
|
|
31
|
-
return { from, to };
|
|
36
|
+
return clampToMaxWindow(requested, to);
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
export function comparisonRange(
|
|
@@ -37,7 +42,7 @@ export function comparisonRange(
|
|
|
37
42
|
): DateRange {
|
|
38
43
|
const from = parseDate(readQuery(ctx, "compareFrom"));
|
|
39
44
|
const to = parseDate(readQuery(ctx, "compareTo"));
|
|
40
|
-
if (from && to) return
|
|
45
|
+
if (from && to) return clampToMaxWindow(from, to);
|
|
41
46
|
const span = current.to.getTime() - current.from.getTime();
|
|
42
47
|
return {
|
|
43
48
|
from: new Date(current.from.getTime() - span),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { SendSummary, MailingTemplate } from "../db";
|
|
2
2
|
import { PROBLEM_STATUSES } from "../types";
|
|
3
3
|
|
|
4
4
|
export type AttentionTone = "error" | "warning" | "neutral";
|
|
@@ -24,7 +24,7 @@ const isStaleDraft = (template: MailingTemplate, now: number): boolean =>
|
|
|
24
24
|
!template.publishedAt &&
|
|
25
25
|
now - new Date(template.updatedAt).getTime() > STALE_DRAFT_DAYS * DAY_MS;
|
|
26
26
|
|
|
27
|
-
function problemsItem(sends:
|
|
27
|
+
function problemsItem(sends: SendSummary[]): AttentionItem[] {
|
|
28
28
|
const problems = sends.filter((send) =>
|
|
29
29
|
PROBLEM_STATUSES.includes(send.status),
|
|
30
30
|
);
|
|
@@ -62,7 +62,7 @@ function staleDraftsItem(templates: MailingTemplate[]): AttentionItem[] {
|
|
|
62
62
|
|
|
63
63
|
function neverSentItem(
|
|
64
64
|
templates: MailingTemplate[],
|
|
65
|
-
sends:
|
|
65
|
+
sends: SendSummary[],
|
|
66
66
|
): AttentionItem[] {
|
|
67
67
|
const used = new Set(sends.map((send) => send.templateSlug));
|
|
68
68
|
const unused = templates.filter(
|
|
@@ -89,8 +89,8 @@ function neverSentItem(
|
|
|
89
89
|
*/
|
|
90
90
|
export interface AttentionWindow {
|
|
91
91
|
templates: MailingTemplate[];
|
|
92
|
-
sends:
|
|
93
|
-
excluded:
|
|
92
|
+
sends: SendSummary[];
|
|
93
|
+
excluded: SendSummary[];
|
|
94
94
|
/** What the configured provider can report; `null` when it is unreachable. */
|
|
95
95
|
tracking: ProviderTracking | null;
|
|
96
96
|
}
|
package/src/services/events.ts
CHANGED
|
@@ -104,14 +104,14 @@ export async function recordEmailEvent(
|
|
|
104
104
|
/**
|
|
105
105
|
* Whether a webhook request carries the tenant's secret.
|
|
106
106
|
*
|
|
107
|
-
* Fails closed on a blank stored secret: the event endpoint is the
|
|
108
|
-
* only unauthenticated route, so "no secret configured" must refuse
|
|
109
|
-
* rather than accept an empty header. The comparison itself is constant-time;
|
|
107
|
+
* Fails closed on a blank or missing stored secret: the event endpoint is the
|
|
108
|
+
* module's only unauthenticated route, so "no secret configured" must refuse
|
|
109
|
+
* everything rather than accept an empty header. The comparison itself is constant-time;
|
|
110
110
|
* only the length is observable.
|
|
111
111
|
*/
|
|
112
112
|
export function webhookSecretMatches(
|
|
113
113
|
provided: string | undefined,
|
|
114
|
-
stored: string,
|
|
114
|
+
stored: string | undefined,
|
|
115
115
|
): boolean {
|
|
116
116
|
if (!stored || !provided) return false;
|
|
117
117
|
const left = Buffer.from(provided);
|
package/src/services/metrics.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { SendSummary } from "../db";
|
|
2
2
|
import {
|
|
3
3
|
BUSINESS_AUDIENCE,
|
|
4
4
|
OPERATIONAL_AUDIENCE,
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
type SendStatus,
|
|
7
7
|
} from "../types";
|
|
8
8
|
|
|
9
|
-
const AUDIENCE_KEEPS: Record<SendAudience, (send:
|
|
9
|
+
const AUDIENCE_KEEPS: Record<SendAudience, (send: SendSummary) => boolean> = {
|
|
10
10
|
[BUSINESS_AUDIENCE]: (send) => !send.isTest,
|
|
11
11
|
[OPERATIONAL_AUDIENCE]: () => true,
|
|
12
12
|
};
|
|
@@ -22,9 +22,9 @@ export function readAudience(raw: string | null | undefined): SendAudience {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export function forAudience(
|
|
25
|
-
sends:
|
|
25
|
+
sends: SendSummary[],
|
|
26
26
|
audience: SendAudience,
|
|
27
|
-
):
|
|
27
|
+
): SendSummary[] {
|
|
28
28
|
return sends.filter(AUDIENCE_KEEPS[audience]);
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -72,13 +72,13 @@ export const ratio = (part: number, whole: number): number =>
|
|
|
72
72
|
whole ? (part / whole) * PERCENT : 0;
|
|
73
73
|
|
|
74
74
|
const countIf = (
|
|
75
|
-
sends:
|
|
76
|
-
predicate: (send:
|
|
75
|
+
sends: SendSummary[],
|
|
76
|
+
predicate: (send: SendSummary) => boolean,
|
|
77
77
|
): number => sends.filter(predicate).length;
|
|
78
78
|
|
|
79
79
|
const byStatus =
|
|
80
80
|
(status: SendStatus) =>
|
|
81
|
-
(send:
|
|
81
|
+
(send: SendSummary): boolean =>
|
|
82
82
|
send.status === status;
|
|
83
83
|
|
|
84
84
|
function median(values: number[]): number {
|
|
@@ -87,7 +87,7 @@ function median(values: number[]): number {
|
|
|
87
87
|
return sorted[Math.floor(sorted.length / 2)] as number;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
export function aggregate(sends:
|
|
90
|
+
export function aggregate(sends: SendSummary[]): Totals {
|
|
91
91
|
const delivered = countIf(sends, (send) =>
|
|
92
92
|
DELIVERED_LIKE.includes(send.status),
|
|
93
93
|
);
|
|
@@ -138,7 +138,7 @@ const dayKey = (date: Date): string =>
|
|
|
138
138
|
* still counted it.
|
|
139
139
|
*/
|
|
140
140
|
export function dailySeries(
|
|
141
|
-
sends:
|
|
141
|
+
sends: SendSummary[],
|
|
142
142
|
from: Date,
|
|
143
143
|
to: Date,
|
|
144
144
|
): SeriesPoint[] {
|
package/src/services/settings.ts
CHANGED
|
@@ -44,15 +44,43 @@ function toRow(values: MailingSettingsValues): Partial<MailingSettings> {
|
|
|
44
44
|
return { ...rest, json_categories: JSON.stringify(categories) };
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/** The tenant's stored settings, without creating them when there are none. */
|
|
48
|
+
export async function findSettings(
|
|
49
|
+
tenantId: string,
|
|
50
|
+
): Promise<MailingSettingsValues | undefined> {
|
|
51
|
+
const existing = await GetModel(SettingsModel, tenantId).getSingleton(
|
|
52
|
+
tenantId,
|
|
53
|
+
);
|
|
54
|
+
return existing ? toValues(existing) : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Creates the tenant's row under its tenant id, so two concurrent first
|
|
59
|
+
* readers cannot both insert one: the loser's insert is refused on the key.
|
|
60
|
+
* Resolves false when another writer created the row first.
|
|
61
|
+
*/
|
|
62
|
+
async function insertKeyedSettings(
|
|
63
|
+
tenantId: string,
|
|
64
|
+
values: MailingSettingsValues,
|
|
65
|
+
): Promise<boolean> {
|
|
66
|
+
const model = GetModel(SettingsModel, tenantId);
|
|
67
|
+
try {
|
|
68
|
+
await model.insert({ ...toRow(values), _id: tenantId });
|
|
69
|
+
return true;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (await model.get(tenantId)) return false;
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
47
76
|
export async function getSettings(
|
|
48
77
|
tenantId: string,
|
|
49
78
|
): Promise<MailingSettingsValues> {
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
if (existing) return toValues(existing);
|
|
79
|
+
const existing = await findSettings(tenantId);
|
|
80
|
+
if (existing) return existing;
|
|
53
81
|
const created = defaults();
|
|
54
|
-
await
|
|
55
|
-
return
|
|
82
|
+
if (await insertKeyedSettings(tenantId, created)) return created;
|
|
83
|
+
return (await findSettings(tenantId)) as MailingSettingsValues;
|
|
56
84
|
}
|
|
57
85
|
|
|
58
86
|
export async function saveSettings(
|
|
@@ -60,9 +88,10 @@ export async function saveSettings(
|
|
|
60
88
|
values: MailingSettingsValues,
|
|
61
89
|
): Promise<MailingSettingsValues> {
|
|
62
90
|
const model = GetModel(SettingsModel, tenantId);
|
|
63
|
-
const existing = await model.getSingleton();
|
|
91
|
+
const existing = await model.getSingleton(tenantId);
|
|
64
92
|
if (existing) await model.update(existing._id, toRow(values));
|
|
65
|
-
else await
|
|
93
|
+
else if (!(await insertKeyedSettings(tenantId, values)))
|
|
94
|
+
await model.update(tenantId, toRow(values));
|
|
66
95
|
return values;
|
|
67
96
|
}
|
|
68
97
|
|
|
@@ -3,11 +3,12 @@ import {
|
|
|
3
3
|
clearEmailEventSubscriptions,
|
|
4
4
|
emailEventTrigger,
|
|
5
5
|
} from "../../automation/email-event-trigger";
|
|
6
|
-
import { authorizedClient } from "../helpers/http";
|
|
6
|
+
import { authorizedClient, createClient } from "../helpers/http";
|
|
7
7
|
import { ensureOwnerSession } from "../helpers/owner";
|
|
8
8
|
|
|
9
9
|
const HTTP_OK = 200;
|
|
10
10
|
const HTTP_FORBIDDEN = 403;
|
|
11
|
+
const SECRET_LENGTH = 48;
|
|
11
12
|
const TABLE = "/api/mailing/tables/templates";
|
|
12
13
|
|
|
13
14
|
interface EventRow {
|
|
@@ -100,6 +101,28 @@ describe("[integration] events", () => {
|
|
|
100
101
|
});
|
|
101
102
|
});
|
|
102
103
|
|
|
104
|
+
describe("[integration] webhook for a tenant without settings", () => {
|
|
105
|
+
// The endpoint is unauthenticated and takes the tenant from the body: reading
|
|
106
|
+
// the secret must not create a settings row for whatever tenant id it names.
|
|
107
|
+
it("refuses the event and stores nothing", async () => {
|
|
108
|
+
const { GetModel } =
|
|
109
|
+
await import("@antelopejs/interface-database-decorators");
|
|
110
|
+
const { SettingsModel } = await import("../../db");
|
|
111
|
+
const tenantId = `unknown-tenant-${Date.now()}`;
|
|
112
|
+
|
|
113
|
+
const hook = await createClient().post(
|
|
114
|
+
"/api/mailing/events/manual",
|
|
115
|
+
{ messageId: "unknown-message", type: "opened", tenantId },
|
|
116
|
+
{ headers: { "x-mailing-webhook-secret": "a".repeat(SECRET_LENGTH) } },
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
expect(hook.status).to.equal(HTTP_FORBIDDEN);
|
|
120
|
+
expect(await GetModel(SettingsModel, tenantId).getAll()).to.have.lengthOf(
|
|
121
|
+
0,
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
103
126
|
describe("[integration] send event purge", () => {
|
|
104
127
|
// The retention cron deletes a send's events in one filtered statement. A
|
|
105
128
|
// wrong predicate there would take another send's history with it, so pin
|
|
@@ -45,6 +45,39 @@ describe("[integration] sends", () => {
|
|
|
45
45
|
expect(detail.data.events[0].type).to.equal(detail.data.send.status);
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
// Metrics load every send of their window; the rendered variables are the
|
|
49
|
+
// one unbounded field and none of them reads it.
|
|
50
|
+
it("reads a metrics window without the rendered variables", async () => {
|
|
51
|
+
const session = await ensureOwnerSession();
|
|
52
|
+
const client = authorizedClient(session.accessToken);
|
|
53
|
+
const created = await client.post(`${TABLE}/new`, {
|
|
54
|
+
slug: "metrics-window",
|
|
55
|
+
name: "Metrics window",
|
|
56
|
+
category: "orders",
|
|
57
|
+
});
|
|
58
|
+
const sent = await client.post(
|
|
59
|
+
`/api/mailing/templates/${created.data[0]}/test-send`,
|
|
60
|
+
{ to: ["metrics@example.test"], locale: "en", data: { any: "value" } },
|
|
61
|
+
);
|
|
62
|
+
expect(sent.status, JSON.stringify(sent.data)).to.equal(HTTP_OK);
|
|
63
|
+
const sendId = sent.data.results[0].sendId as string;
|
|
64
|
+
|
|
65
|
+
const { GetModel } =
|
|
66
|
+
await import("@antelopejs/interface-database-decorators");
|
|
67
|
+
const { DEFAULT_TENANT_ID } =
|
|
68
|
+
await import("@antelopejs/interface-dms/constants");
|
|
69
|
+
const { SendModel } = await import("../../db");
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
const rows = await GetModel(SendModel, DEFAULT_TENANT_ID).listBetween(
|
|
72
|
+
new Date(now - DAY_MS),
|
|
73
|
+
new Date(now + DAY_MS),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const row = rows.find((candidate) => candidate._id === sendId);
|
|
77
|
+
expect(row?.templateSlug).to.equal("metrics-window");
|
|
78
|
+
expect(row).to.not.have.property("json_variables");
|
|
79
|
+
});
|
|
80
|
+
|
|
48
81
|
it("refuses a real send on a draft template", async () => {
|
|
49
82
|
const session = await ensureOwnerSession();
|
|
50
83
|
const client = authorizedClient(session.accessToken);
|
|
@@ -48,3 +48,81 @@ describe("[integration] settings", () => {
|
|
|
48
48
|
expect(restored.status).to.equal(HTTP_OK);
|
|
49
49
|
});
|
|
50
50
|
});
|
|
51
|
+
|
|
52
|
+
const CONCURRENT_READERS = 5;
|
|
53
|
+
const LEGACY_RETENTION_DAYS = 45;
|
|
54
|
+
const LEGACY_SECRET = "l".repeat(48);
|
|
55
|
+
|
|
56
|
+
async function loadSettingsModules() {
|
|
57
|
+
const { GetModel } =
|
|
58
|
+
await import("@antelopejs/interface-database-decorators");
|
|
59
|
+
const { SettingsModel } = await import("../../db");
|
|
60
|
+
const settings = await import("../../services/settings");
|
|
61
|
+
return {
|
|
62
|
+
...settings,
|
|
63
|
+
rowsOf: (tenantId: string) => GetModel(SettingsModel, tenantId).getAll(),
|
|
64
|
+
insertLegacyRow: (tenantId: string) =>
|
|
65
|
+
GetModel(SettingsModel, tenantId).insert({
|
|
66
|
+
fallbackLocale: "en",
|
|
67
|
+
logRetentionDays: LEGACY_RETENTION_DAYS,
|
|
68
|
+
blockOnMissingVariables: false,
|
|
69
|
+
senderName: "",
|
|
70
|
+
senderEmail: "",
|
|
71
|
+
replyTo: "",
|
|
72
|
+
json_categories: "[]",
|
|
73
|
+
webhookSecret: LEGACY_SECRET,
|
|
74
|
+
}),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe("[integration] settings row per tenant", () => {
|
|
79
|
+
// Every request path reads the settings, so a tenant's first reads arrive
|
|
80
|
+
// together. Each used to insert its own default row with its own webhook
|
|
81
|
+
// secret; the row is now keyed by the tenant id so only one can land.
|
|
82
|
+
it("creates a single row when first readers race", async () => {
|
|
83
|
+
const { getSettings, rowsOf } = await loadSettingsModules();
|
|
84
|
+
const tenantId = `settings-race-${Date.now()}`;
|
|
85
|
+
|
|
86
|
+
const reads = await Promise.all(
|
|
87
|
+
Array.from({ length: CONCURRENT_READERS }, () => getSettings(tenantId)),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const rows = await rowsOf(tenantId);
|
|
91
|
+
expect(rows).to.have.lengthOf(1);
|
|
92
|
+
expect(rows[0]?._id).to.equal(tenantId);
|
|
93
|
+
for (const read of reads) {
|
|
94
|
+
expect(read.webhookSecret).to.equal(rows[0]?.webhookSecret);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("keeps reading and updating a row written before the key existed", async () => {
|
|
99
|
+
const { getSettings, saveSettings, rowsOf, insertLegacyRow } =
|
|
100
|
+
await loadSettingsModules();
|
|
101
|
+
const tenantId = `settings-legacy-${Date.now()}`;
|
|
102
|
+
await insertLegacyRow(tenantId);
|
|
103
|
+
|
|
104
|
+
const read = await getSettings(tenantId);
|
|
105
|
+
expect(read.webhookSecret).to.equal(LEGACY_SECRET);
|
|
106
|
+
await saveSettings(tenantId, {
|
|
107
|
+
...read,
|
|
108
|
+
logRetentionDays: UPDATED_RETENTION_DAYS,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const rows = await rowsOf(tenantId);
|
|
112
|
+
expect(rows).to.have.lengthOf(1);
|
|
113
|
+
expect(rows[0]?.logRetentionDays).to.equal(UPDATED_RETENTION_DAYS);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("saves a tenant's first settings under the tenant id", async () => {
|
|
117
|
+
const { getSettings, saveSettings, rowsOf } = await loadSettingsModules();
|
|
118
|
+
const tenantId = `settings-first-save-${Date.now()}`;
|
|
119
|
+
const values = await getSettings(`settings-template-${Date.now()}`);
|
|
120
|
+
|
|
121
|
+
await saveSettings(tenantId, { ...values, webhookSecret: LEGACY_SECRET });
|
|
122
|
+
|
|
123
|
+
const rows = await rowsOf(tenantId);
|
|
124
|
+
expect(rows).to.have.lengthOf(1);
|
|
125
|
+
expect(rows[0]?._id).to.equal(tenantId);
|
|
126
|
+
expect(rows[0]?.webhookSecret).to.equal(LEGACY_SECRET);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -5,6 +5,7 @@ import { ensureOwnerSession } from "../helpers/owner";
|
|
|
5
5
|
const HTTP_OK = 200;
|
|
6
6
|
const HTTP_BAD_REQUEST = 400;
|
|
7
7
|
const HTTP_NOT_FOUND = 404;
|
|
8
|
+
const HTTP_CONFLICT = 409;
|
|
8
9
|
const TABLE = "/api/mailing/tables/templates";
|
|
9
10
|
|
|
10
11
|
interface TemplateRow {
|
|
@@ -116,6 +117,43 @@ describe("[integration] templates table", () => {
|
|
|
116
117
|
expect(row.data.category).to.equal("billing");
|
|
117
118
|
});
|
|
118
119
|
|
|
120
|
+
// Sends resolve a template by slug and only the create paths check it is
|
|
121
|
+
// free, so an edit must neither move it nor, by omitting it, clear it.
|
|
122
|
+
it("refuses a slug change on edit and keeps the slug when it is omitted", async () => {
|
|
123
|
+
const session = await ensureOwnerSession();
|
|
124
|
+
const client = authorizedClient(session.accessToken);
|
|
125
|
+
const created = await client.post(`${TABLE}/new`, {
|
|
126
|
+
slug: "fixed-slug",
|
|
127
|
+
name: "Fixed slug",
|
|
128
|
+
category: "orders",
|
|
129
|
+
});
|
|
130
|
+
const id = created.data[0] as string;
|
|
131
|
+
|
|
132
|
+
const moved = await client.put(`${TABLE}/edit?id=${id}`, {
|
|
133
|
+
slug: "order-confirmed",
|
|
134
|
+
name: "Fixed slug",
|
|
135
|
+
category: "orders",
|
|
136
|
+
});
|
|
137
|
+
expect(moved.status).to.equal(HTTP_CONFLICT);
|
|
138
|
+
expect(JSON.stringify(moved.data)).to.contain("slug_immutable");
|
|
139
|
+
|
|
140
|
+
const omitted = await client.put(`${TABLE}/edit?id=${id}`, {
|
|
141
|
+
name: "Renamed without slug",
|
|
142
|
+
category: "orders",
|
|
143
|
+
});
|
|
144
|
+
expect(omitted.status, JSON.stringify(omitted.data)).to.equal(HTTP_OK);
|
|
145
|
+
const row = await client.get(`${TABLE}/get?id=${id}`);
|
|
146
|
+
expect(row.data.slug).to.equal("fixed-slug");
|
|
147
|
+
expect(row.data.name).to.equal("Renamed without slug");
|
|
148
|
+
|
|
149
|
+
const { GetModel } =
|
|
150
|
+
await import("@antelopejs/interface-database-decorators");
|
|
151
|
+
const { DEFAULT_TENANT_ID } =
|
|
152
|
+
await import("@antelopejs/interface-dms/constants");
|
|
153
|
+
const { TemplateModel } = await import("../../db");
|
|
154
|
+
await GetModel(TemplateModel, DEFAULT_TENANT_ID).delete(id);
|
|
155
|
+
});
|
|
156
|
+
|
|
119
157
|
it("refuses an unknown source template", async () => {
|
|
120
158
|
const session = await ensureOwnerSession();
|
|
121
159
|
const client = authorizedClient(session.accessToken);
|
|
@@ -211,12 +249,17 @@ describe("[integration] template content and status", () => {
|
|
|
211
249
|
it("previews the current content with test data and reports missing variables", async () => {
|
|
212
250
|
const session = await ensureOwnerSession();
|
|
213
251
|
const client = authorizedClient(session.accessToken);
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
252
|
+
// Looked up by slug rather than on the table's first page, which other
|
|
253
|
+
// tests fill with their own templates.
|
|
254
|
+
const { GetModel } =
|
|
255
|
+
await import("@antelopejs/interface-database-decorators");
|
|
256
|
+
const { DEFAULT_TENANT_ID } =
|
|
257
|
+
await import("@antelopejs/interface-dms/constants");
|
|
258
|
+
const { TemplateModel } = await import("../../db");
|
|
259
|
+
const welcome = await GetModel(TemplateModel, DEFAULT_TENANT_ID).getBySlug(
|
|
260
|
+
"welcome",
|
|
261
|
+
);
|
|
262
|
+
const id = welcome?._id as string;
|
|
220
263
|
const preview = await client.post(`/api/mailing/templates/${id}/preview`, {
|
|
221
264
|
locale: "en",
|
|
222
265
|
data: {},
|