mailery 0.1.2 → 0.2.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 +4 -4
- package/dist/admin/spa/index-csgOs39s.js +41 -0
- package/dist/admin/spa/index-csgOs39s.js.map +1 -0
- package/dist/admin/spa/index.html +1 -1
- package/dist/admin/spa/{template-editor-DUZfzI5F.js → template-editor-BuYSO1FP.js} +2 -2
- package/dist/admin/spa/{template-editor-DUZfzI5F.js.map → template-editor-BuYSO1FP.js.map} +1 -1
- package/dist/index.cjs +657 -133
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +657 -134
- package/dist/index.js.map +1 -1
- package/dist/{null-OzIqP7A8.d.cts → null-DaisDvB_.d.cts} +123 -59
- package/dist/{null-OzIqP7A8.d.ts → null-DaisDvB_.d.ts} +123 -59
- package/dist/testing.cjs +365 -136
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +3 -3
- package/dist/testing.d.ts +3 -3
- package/dist/testing.js +365 -136
- package/dist/testing.js.map +1 -1
- package/package.json +29 -4
- package/dist/admin/spa/index-DlhleV6M.js +0 -41
- package/dist/admin/spa/index-DlhleV6M.js.map +0 -1
package/dist/index.cjs
CHANGED
|
@@ -4,7 +4,6 @@ var mongodb = require('mongodb');
|
|
|
4
4
|
var crypto2 = require('crypto');
|
|
5
5
|
var sgMail = require('@sendgrid/mail');
|
|
6
6
|
var zod = require('zod');
|
|
7
|
-
var bullmq = require('bullmq');
|
|
8
7
|
var IORedis = require('ioredis');
|
|
9
8
|
var Handlebars = require('handlebars');
|
|
10
9
|
var htmlToText = require('html-to-text');
|
|
@@ -582,7 +581,8 @@ async function ensureIndexes(db, prefix = "mailer_") {
|
|
|
582
581
|
{ key: { flowRunId: 1 }, sparse: true },
|
|
583
582
|
{ key: { broadcastId: 1 }, sparse: true },
|
|
584
583
|
{ key: { providerMessageId: 1 }, sparse: true },
|
|
585
|
-
{ key: { status: 1, queuedAt: 1 } }
|
|
584
|
+
{ key: { status: 1, queuedAt: 1 } },
|
|
585
|
+
{ key: { status: 1, updatedAt: 1 } }
|
|
586
586
|
]),
|
|
587
587
|
c.suppressions.createIndexes([
|
|
588
588
|
{ key: { email: 1, scope: 1 }, unique: true, partialFilterExpression: { email: { $type: "string" } } },
|
|
@@ -729,6 +729,104 @@ function verifyDoiToken(token, secret, now = /* @__PURE__ */ new Date()) {
|
|
|
729
729
|
if (body.x < now.getTime()) return null;
|
|
730
730
|
return { externalId: body.i, expiresAt: new Date(body.x) };
|
|
731
731
|
}
|
|
732
|
+
var QUEUE_NAMES = {
|
|
733
|
+
tick: "mailer-tick",
|
|
734
|
+
advance: "mailer-advance",
|
|
735
|
+
send: "mailer-send",
|
|
736
|
+
webhook: "mailer-webhook"
|
|
737
|
+
};
|
|
738
|
+
var BullDriver = class _BullDriver {
|
|
739
|
+
queues;
|
|
740
|
+
redis;
|
|
741
|
+
bullQueues;
|
|
742
|
+
workers = null;
|
|
743
|
+
bull;
|
|
744
|
+
static async create(redisConfig) {
|
|
745
|
+
let bull;
|
|
746
|
+
try {
|
|
747
|
+
bull = await import('bullmq');
|
|
748
|
+
} catch {
|
|
749
|
+
throw new Error(
|
|
750
|
+
"mailery: queue driver 'bull' requires the 'bullmq' peer dependency. Run `npm install bullmq ioredis`."
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
const redis = isRedisLike(redisConfig) ? redisConfig : connect(redisConfig);
|
|
754
|
+
return new _BullDriver(bull, redis);
|
|
755
|
+
}
|
|
756
|
+
constructor(bull, redis) {
|
|
757
|
+
this.bull = bull;
|
|
758
|
+
this.redis = redis;
|
|
759
|
+
const opts = { connection: redis };
|
|
760
|
+
this.bullQueues = {
|
|
761
|
+
tick: new bull.Queue(QUEUE_NAMES.tick, opts),
|
|
762
|
+
advance: new bull.Queue(QUEUE_NAMES.advance, opts),
|
|
763
|
+
send: new bull.Queue(QUEUE_NAMES.send, opts),
|
|
764
|
+
webhook: new bull.Queue(QUEUE_NAMES.webhook, opts)
|
|
765
|
+
};
|
|
766
|
+
this.queues = {
|
|
767
|
+
tick: adaptBullQueue(this.bullQueues.tick),
|
|
768
|
+
advance: adaptBullQueue(this.bullQueues.advance),
|
|
769
|
+
send: adaptBullQueue(this.bullQueues.send),
|
|
770
|
+
webhook: adaptBullQueue(this.bullQueues.webhook)
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
async scheduleRepeatingTick(intervalSeconds) {
|
|
774
|
+
await this.bullQueues.tick.upsertJobScheduler(
|
|
775
|
+
"mailer-tick-repeat",
|
|
776
|
+
{ every: intervalSeconds * 1e3 },
|
|
777
|
+
{ name: "tick", data: {} }
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
async startWorkers(opts) {
|
|
781
|
+
if (this.workers) return;
|
|
782
|
+
const base = { connection: this.redis };
|
|
783
|
+
const { Worker } = this.bull;
|
|
784
|
+
const tick = new Worker(
|
|
785
|
+
QUEUE_NAMES.tick,
|
|
786
|
+
async (job) => opts.handlers.tick(job.data),
|
|
787
|
+
{ ...base, concurrency: 1 }
|
|
788
|
+
);
|
|
789
|
+
const advance = new Worker(
|
|
790
|
+
QUEUE_NAMES.advance,
|
|
791
|
+
async (job) => opts.handlers.advance(job.data),
|
|
792
|
+
{ ...base, concurrency: 10 }
|
|
793
|
+
);
|
|
794
|
+
const send = new Worker(
|
|
795
|
+
QUEUE_NAMES.send,
|
|
796
|
+
async (job) => opts.handlers.send(job.data),
|
|
797
|
+
{
|
|
798
|
+
...base,
|
|
799
|
+
concurrency: opts.concurrency.send,
|
|
800
|
+
limiter: opts.sendRateLimit ? { max: opts.sendRateLimit.max, duration: opts.sendRateLimit.durationMs } : void 0
|
|
801
|
+
}
|
|
802
|
+
);
|
|
803
|
+
const webhook = new Worker(
|
|
804
|
+
QUEUE_NAMES.webhook,
|
|
805
|
+
async (job) => opts.handlers.webhook(job.data),
|
|
806
|
+
{ ...base, concurrency: 4 }
|
|
807
|
+
);
|
|
808
|
+
this.workers = { tick, advance, send, webhook };
|
|
809
|
+
}
|
|
810
|
+
async stopWorkers() {
|
|
811
|
+
if (!this.workers) return;
|
|
812
|
+
await Promise.all([
|
|
813
|
+
this.workers.tick.close(),
|
|
814
|
+
this.workers.advance.close(),
|
|
815
|
+
this.workers.send.close(),
|
|
816
|
+
this.workers.webhook.close()
|
|
817
|
+
]);
|
|
818
|
+
this.workers = null;
|
|
819
|
+
}
|
|
820
|
+
async close() {
|
|
821
|
+
await this.stopWorkers();
|
|
822
|
+
await Promise.all([
|
|
823
|
+
this.bullQueues.tick.close(),
|
|
824
|
+
this.bullQueues.advance.close(),
|
|
825
|
+
this.bullQueues.send.close(),
|
|
826
|
+
this.bullQueues.webhook.close()
|
|
827
|
+
]);
|
|
828
|
+
}
|
|
829
|
+
};
|
|
732
830
|
function adaptBullQueue(q) {
|
|
733
831
|
return {
|
|
734
832
|
add: (name, data, opts) => q.add(name, data, opts),
|
|
@@ -736,39 +834,16 @@ function adaptBullQueue(q) {
|
|
|
736
834
|
close: () => q.close()
|
|
737
835
|
};
|
|
738
836
|
}
|
|
739
|
-
function
|
|
740
|
-
return
|
|
741
|
-
add: async () => void 0,
|
|
742
|
-
getWaitingCount: async () => 0,
|
|
743
|
-
close: async () => void 0
|
|
744
|
-
};
|
|
745
|
-
}
|
|
746
|
-
function noopQueues() {
|
|
747
|
-
return {
|
|
748
|
-
tick: noopQueueAPI(),
|
|
749
|
-
advance: noopQueueAPI(),
|
|
750
|
-
send: noopQueueAPI(),
|
|
751
|
-
webhook: noopQueueAPI()
|
|
752
|
-
};
|
|
753
|
-
}
|
|
754
|
-
function namespacedQueueNames(prefix) {
|
|
755
|
-
return {
|
|
756
|
-
tick: "mailer-tick",
|
|
757
|
-
advance: "mailer-advance",
|
|
758
|
-
send: "mailer-send",
|
|
759
|
-
webhook: "mailer-webhook"
|
|
760
|
-
};
|
|
837
|
+
function isRedisLike(x) {
|
|
838
|
+
return !!x && typeof x === "object" && typeof x.get === "function" && typeof x.set === "function";
|
|
761
839
|
}
|
|
762
|
-
function
|
|
763
|
-
if (isRedisLike(opts)) return opts;
|
|
840
|
+
function connect(opts) {
|
|
764
841
|
const config = {
|
|
765
842
|
maxRetriesPerRequest: null,
|
|
766
843
|
// BullMQ requirement
|
|
767
844
|
enableReadyCheck: false
|
|
768
845
|
};
|
|
769
|
-
if (opts.url)
|
|
770
|
-
return new IORedis__default.default(opts.url, config);
|
|
771
|
-
}
|
|
846
|
+
if (opts.url) return new IORedis__default.default(opts.url, config);
|
|
772
847
|
return new IORedis__default.default({
|
|
773
848
|
...config,
|
|
774
849
|
host: opts.host ?? "127.0.0.1",
|
|
@@ -779,69 +854,198 @@ function makeRedis(opts) {
|
|
|
779
854
|
tls: opts.tls ? {} : void 0
|
|
780
855
|
});
|
|
781
856
|
}
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
857
|
+
|
|
858
|
+
// src/server/queues/agenda.ts
|
|
859
|
+
var QUEUE_NAMES2 = {
|
|
860
|
+
tick: "mailer-tick",
|
|
861
|
+
advance: "mailer-advance",
|
|
862
|
+
send: "mailer-send",
|
|
863
|
+
webhook: "mailer-webhook"
|
|
864
|
+
};
|
|
865
|
+
var AgendaDriver = class _AgendaDriver {
|
|
866
|
+
queues;
|
|
867
|
+
agenda;
|
|
868
|
+
agendaMod;
|
|
869
|
+
sendLimiter = null;
|
|
870
|
+
started = false;
|
|
871
|
+
static async create(opts) {
|
|
872
|
+
let agendaMod;
|
|
873
|
+
let backendMod;
|
|
874
|
+
try {
|
|
875
|
+
agendaMod = await import('agenda');
|
|
876
|
+
backendMod = await import('@agendajs/mongo-backend');
|
|
877
|
+
} catch {
|
|
878
|
+
throw new Error(
|
|
879
|
+
"mailery: queue driver 'agenda' requires the 'agenda' and '@agendajs/mongo-backend' peer dependencies. Run `npm install agenda @agendajs/mongo-backend bottleneck`."
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
const backend = new backendMod.MongoBackend({
|
|
883
|
+
mongo: opts.db,
|
|
884
|
+
collection: opts.collectionName ?? "_mailerJobs"
|
|
885
|
+
});
|
|
886
|
+
const agenda = new agendaMod.Agenda({
|
|
887
|
+
backend,
|
|
888
|
+
processEvery: `${opts.processEverySeconds ?? 5} seconds`,
|
|
889
|
+
defaultLockLifetime: (opts.lockLifetimeSeconds ?? 10 * 60) * 1e3,
|
|
890
|
+
maxConcurrency: 50,
|
|
891
|
+
defaultConcurrency: 5
|
|
892
|
+
});
|
|
893
|
+
return new _AgendaDriver(agenda, agendaMod, opts.db);
|
|
894
|
+
}
|
|
895
|
+
db;
|
|
896
|
+
constructor(agenda, agendaMod, db) {
|
|
897
|
+
this.agenda = agenda;
|
|
898
|
+
this.agendaMod = agendaMod;
|
|
899
|
+
this.db = db;
|
|
900
|
+
this.queues = {
|
|
901
|
+
tick: this.makeQueueAPI(QUEUE_NAMES2.tick),
|
|
902
|
+
advance: this.makeQueueAPI(QUEUE_NAMES2.advance),
|
|
903
|
+
send: this.makeQueueAPI(QUEUE_NAMES2.send),
|
|
904
|
+
webhook: this.makeQueueAPI(QUEUE_NAMES2.webhook)
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
makeQueueAPI(name) {
|
|
908
|
+
return {
|
|
909
|
+
add: async (_jobName, data, opts) => {
|
|
910
|
+
const payload = { ...data };
|
|
911
|
+
if (opts?.jobId) {
|
|
912
|
+
payload.__jobId = opts.jobId;
|
|
913
|
+
if (await this.findPending(name, opts.jobId)) return;
|
|
914
|
+
}
|
|
915
|
+
const job = this.agenda.create(name, payload);
|
|
916
|
+
if (opts?.delay) job.schedule(new Date(Date.now() + opts.delay));
|
|
917
|
+
await job.save();
|
|
918
|
+
},
|
|
919
|
+
getWaitingCount: async () => {
|
|
920
|
+
return this.jobsCollection().countDocuments({
|
|
921
|
+
name,
|
|
922
|
+
$or: [{ lockedAt: null }, { lockedAt: { $exists: false } }],
|
|
923
|
+
nextRunAt: { $lte: /* @__PURE__ */ new Date() }
|
|
924
|
+
});
|
|
925
|
+
},
|
|
926
|
+
close: async () => {
|
|
927
|
+
}
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
/** Direct access to the Mongo collection Agenda persists jobs into. */
|
|
931
|
+
jobsCollection() {
|
|
932
|
+
return this.db.collection(this.collectionName());
|
|
933
|
+
}
|
|
934
|
+
collectionName() {
|
|
935
|
+
return "_mailerJobs";
|
|
936
|
+
}
|
|
937
|
+
async findPending(name, jobId) {
|
|
938
|
+
return this.jobsCollection().findOne({
|
|
939
|
+
name,
|
|
940
|
+
"data.__jobId": jobId,
|
|
941
|
+
$or: [{ lastFinishedAt: null }, { lastFinishedAt: { $exists: false } }]
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
async scheduleRepeatingTick(intervalSeconds) {
|
|
945
|
+
if (!this.started) {
|
|
946
|
+
this.agenda.define(QUEUE_NAMES2.tick, async () => {
|
|
947
|
+
}, { concurrency: 1 });
|
|
948
|
+
await this.agenda.start();
|
|
949
|
+
this.started = true;
|
|
950
|
+
}
|
|
951
|
+
await this.agenda.every(`${intervalSeconds} seconds`, QUEUE_NAMES2.tick);
|
|
952
|
+
}
|
|
953
|
+
async startWorkers(opts) {
|
|
954
|
+
const exp = this.agendaMod.backoffStrategies.exponential;
|
|
955
|
+
if (opts.sendRateLimit) {
|
|
956
|
+
try {
|
|
957
|
+
const Bottleneck = (await import('bottleneck')).default;
|
|
958
|
+
this.sendLimiter = new Bottleneck({
|
|
959
|
+
minTime: Math.ceil(opts.sendRateLimit.durationMs / opts.sendRateLimit.max),
|
|
960
|
+
maxConcurrent: opts.concurrency.send
|
|
961
|
+
});
|
|
962
|
+
} catch {
|
|
963
|
+
throw new Error(
|
|
964
|
+
"mailery: queue driver 'agenda' with sendRateLimit requires the 'bottleneck' peer dependency. Run `npm install bottleneck`."
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
const retryBackoff = exp({ delay: 6e4, maxRetries: Math.max(0, opts.retryAttempts - 1), factor: 2 });
|
|
969
|
+
this.agenda.define(QUEUE_NAMES2.tick, async (job) => {
|
|
970
|
+
await opts.handlers.tick(job.attrs.data);
|
|
971
|
+
}, { concurrency: 1 });
|
|
972
|
+
this.agenda.define(QUEUE_NAMES2.advance, async (job) => {
|
|
973
|
+
await opts.handlers.advance(job.attrs.data);
|
|
974
|
+
}, { concurrency: 10, backoff: retryBackoff });
|
|
975
|
+
this.agenda.define(QUEUE_NAMES2.send, async (job) => {
|
|
976
|
+
const data = job.attrs.data;
|
|
977
|
+
if (this.sendLimiter) {
|
|
978
|
+
await this.sendLimiter.schedule(() => opts.handlers.send(data));
|
|
979
|
+
} else {
|
|
980
|
+
await opts.handlers.send(data);
|
|
981
|
+
}
|
|
982
|
+
}, { concurrency: opts.concurrency.send, backoff: retryBackoff });
|
|
983
|
+
this.agenda.define(QUEUE_NAMES2.webhook, async (job) => {
|
|
984
|
+
await opts.handlers.webhook(job.attrs.data);
|
|
985
|
+
}, { concurrency: 4, backoff: retryBackoff });
|
|
986
|
+
if (!this.started) {
|
|
987
|
+
await this.agenda.start();
|
|
988
|
+
this.started = true;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
async stopWorkers() {
|
|
992
|
+
if (!this.started) return;
|
|
993
|
+
await this.agenda.stop();
|
|
994
|
+
this.started = false;
|
|
995
|
+
if (this.sendLimiter) {
|
|
996
|
+
await this.sendLimiter.stop({ dropWaitingJobs: true }).catch(() => {
|
|
997
|
+
});
|
|
998
|
+
this.sendLimiter = null;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
async close() {
|
|
1002
|
+
await this.stopWorkers();
|
|
1003
|
+
}
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
// src/server/queues/noop.ts
|
|
1007
|
+
function noopQueueAPI() {
|
|
794
1008
|
return {
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
send: adaptBullQueue(bullQueues.send),
|
|
799
|
-
webhook: adaptBullQueue(bullQueues.webhook)
|
|
800
|
-
},
|
|
801
|
-
bullQueues
|
|
1009
|
+
add: async () => void 0,
|
|
1010
|
+
getWaitingCount: async () => 0,
|
|
1011
|
+
close: async () => void 0
|
|
802
1012
|
};
|
|
803
1013
|
}
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
}
|
|
811
|
-
function createWorkers(input) {
|
|
812
|
-
const names = namespacedQueueNames();
|
|
813
|
-
const base = { connection: input.redis };
|
|
814
|
-
const tick = new bullmq.Worker(names.tick, async (job) => input.handlers.tick(job.data), {
|
|
815
|
-
...base,
|
|
816
|
-
concurrency: 1
|
|
817
|
-
// single tick driver per worker process
|
|
818
|
-
});
|
|
819
|
-
const advance = new bullmq.Worker(
|
|
820
|
-
names.advance,
|
|
821
|
-
async (job) => input.handlers.advance(job.data),
|
|
822
|
-
{ ...base, concurrency: 10 }
|
|
823
|
-
);
|
|
824
|
-
const sendOpts = {
|
|
825
|
-
...base,
|
|
826
|
-
concurrency: input.concurrency.send,
|
|
827
|
-
limiter: input.sendRateLimit ? { max: input.sendRateLimit.max, duration: input.sendRateLimit.durationMs } : void 0
|
|
1014
|
+
var NoopDriver = class {
|
|
1015
|
+
queues = {
|
|
1016
|
+
tick: noopQueueAPI(),
|
|
1017
|
+
advance: noopQueueAPI(),
|
|
1018
|
+
send: noopQueueAPI(),
|
|
1019
|
+
webhook: noopQueueAPI()
|
|
828
1020
|
};
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
async function
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
1021
|
+
async scheduleRepeatingTick(_intervalSeconds) {
|
|
1022
|
+
}
|
|
1023
|
+
async startWorkers(_opts) {
|
|
1024
|
+
}
|
|
1025
|
+
async stopWorkers() {
|
|
1026
|
+
}
|
|
1027
|
+
async close() {
|
|
1028
|
+
}
|
|
1029
|
+
};
|
|
1030
|
+
|
|
1031
|
+
// src/server/queues/index.ts
|
|
1032
|
+
async function createQueueDriver(config, fallbackDb) {
|
|
1033
|
+
switch (config.driver) {
|
|
1034
|
+
case "bull":
|
|
1035
|
+
return BullDriver.create(config.redis);
|
|
1036
|
+
case "agenda":
|
|
1037
|
+
return AgendaDriver.create({
|
|
1038
|
+
db: config.db ?? fallbackDb,
|
|
1039
|
+
processEverySeconds: config.processEverySeconds,
|
|
1040
|
+
lockLifetimeSeconds: config.lockLifetimeSeconds,
|
|
1041
|
+
collectionName: config.collectionName
|
|
1042
|
+
});
|
|
1043
|
+
case "noop":
|
|
1044
|
+
return new NoopDriver();
|
|
1045
|
+
default: {
|
|
1046
|
+
throw new Error(`mailery: unknown queue driver`);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
845
1049
|
}
|
|
846
1050
|
|
|
847
1051
|
// src/server/runner/triggers.ts
|
|
@@ -1334,7 +1538,8 @@ async function dispatchSend(sendId, ctx) {
|
|
|
1334
1538
|
status: "sending",
|
|
1335
1539
|
fromName: rendered.fromName,
|
|
1336
1540
|
fromEmail: rendered.fromEmail,
|
|
1337
|
-
subject: rendered.subject
|
|
1541
|
+
subject: rendered.subject,
|
|
1542
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1338
1543
|
}
|
|
1339
1544
|
}
|
|
1340
1545
|
);
|
|
@@ -1408,6 +1613,7 @@ function buildRenderContext(contact, run, vars, ctx) {
|
|
|
1408
1613
|
};
|
|
1409
1614
|
}
|
|
1410
1615
|
function newSendDoc(input) {
|
|
1616
|
+
const now = /* @__PURE__ */ new Date();
|
|
1411
1617
|
return {
|
|
1412
1618
|
_id: input._id,
|
|
1413
1619
|
dedupeKey: input.dedupeKey,
|
|
@@ -1438,7 +1644,8 @@ function newSendDoc(input) {
|
|
|
1438
1644
|
clickedLinks: [],
|
|
1439
1645
|
unsubscribedAt: null,
|
|
1440
1646
|
complainedAt: null,
|
|
1441
|
-
queuedAt:
|
|
1647
|
+
queuedAt: now,
|
|
1648
|
+
updatedAt: now,
|
|
1442
1649
|
sentAt: null,
|
|
1443
1650
|
deliveredAt: null
|
|
1444
1651
|
};
|
|
@@ -1920,6 +2127,7 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
|
|
|
1920
2127
|
unsubscribedAt: null,
|
|
1921
2128
|
complainedAt: null,
|
|
1922
2129
|
queuedAt: /* @__PURE__ */ new Date(),
|
|
2130
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
1923
2131
|
sentAt: null,
|
|
1924
2132
|
deliveredAt: null
|
|
1925
2133
|
};
|
|
@@ -1992,13 +2200,36 @@ async function promoteSoftBounces(ctx) {
|
|
|
1992
2200
|
}
|
|
1993
2201
|
|
|
1994
2202
|
// src/server/runner/tick.ts
|
|
2203
|
+
var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
1995
2204
|
async function runTick(ctx) {
|
|
2205
|
+
await ctx.collections.health.updateOne(
|
|
2206
|
+
{ _id: "singleton" },
|
|
2207
|
+
{
|
|
2208
|
+
$set: { updatedAt: /* @__PURE__ */ new Date() },
|
|
2209
|
+
$setOnInsert: {
|
|
2210
|
+
_id: "singleton",
|
|
2211
|
+
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
2212
|
+
windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
|
|
2213
|
+
status: "healthy",
|
|
2214
|
+
trippedAt: null,
|
|
2215
|
+
trippedReason: null,
|
|
2216
|
+
manuallyResumedAt: null,
|
|
2217
|
+
counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
|
|
2218
|
+
rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
|
|
2219
|
+
}
|
|
2220
|
+
},
|
|
2221
|
+
{ upsert: true }
|
|
2222
|
+
).catch(() => {
|
|
2223
|
+
});
|
|
1996
2224
|
await processNewlyFiredEventTriggers(ctx).catch((err) => {
|
|
1997
2225
|
console.error("mailery: triggers scan failed", err);
|
|
1998
2226
|
});
|
|
1999
2227
|
await sweepStrandedFlowRuns(ctx).catch((err) => {
|
|
2000
2228
|
console.error("mailery: sweep failed", err);
|
|
2001
2229
|
});
|
|
2230
|
+
await sweepStrandedSends(ctx).catch((err) => {
|
|
2231
|
+
console.error("mailery: stranded-send sweep failed", err);
|
|
2232
|
+
});
|
|
2002
2233
|
await drainOutbox(ctx).catch((err) => {
|
|
2003
2234
|
console.error("mailery: outbox drain failed", err);
|
|
2004
2235
|
});
|
|
@@ -2012,6 +2243,24 @@ async function runTick(ctx) {
|
|
|
2012
2243
|
console.error("mailery: soft-bounce promotion failed", err);
|
|
2013
2244
|
});
|
|
2014
2245
|
}
|
|
2246
|
+
async function sweepStrandedSends(ctx) {
|
|
2247
|
+
const cutoff = new Date(Date.now() - STRANDED_SEND_THRESHOLD_MS);
|
|
2248
|
+
const cursor = ctx.collections.sends.find(
|
|
2249
|
+
{ status: "sending", updatedAt: { $lt: cutoff } },
|
|
2250
|
+
{ projection: { _id: 1 } }
|
|
2251
|
+
).limit(500);
|
|
2252
|
+
for await (const row of cursor) {
|
|
2253
|
+
const reset = await ctx.collections.sends.updateOne(
|
|
2254
|
+
{ _id: row._id, status: "sending", updatedAt: { $lt: cutoff } },
|
|
2255
|
+
{ $set: { status: "queued", updatedAt: /* @__PURE__ */ new Date() } }
|
|
2256
|
+
);
|
|
2257
|
+
if (reset.modifiedCount === 0) continue;
|
|
2258
|
+
await ctx.queues.send.add("send", { sendId: String(row._id) }, {
|
|
2259
|
+
attempts: ctx.config.sendRetryAttempts,
|
|
2260
|
+
backoff: { type: "exponential", delay: 6e4 }
|
|
2261
|
+
});
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2015
2264
|
async function drainOutbox(ctx) {
|
|
2016
2265
|
const batch = await ctx.collections.outbox.find({ status: "pending" }).sort({ enqueuedAt: 1 }).limit(200).toArray();
|
|
2017
2266
|
for (const row of batch) {
|
|
@@ -2182,12 +2431,11 @@ var Mailer = class _Mailer {
|
|
|
2182
2431
|
collections;
|
|
2183
2432
|
adapter;
|
|
2184
2433
|
providers;
|
|
2185
|
-
redis;
|
|
2186
2434
|
queues;
|
|
2187
2435
|
config;
|
|
2188
2436
|
events;
|
|
2189
|
-
|
|
2190
|
-
|
|
2437
|
+
queueDriver;
|
|
2438
|
+
workersStarted = false;
|
|
2191
2439
|
runnerContext;
|
|
2192
2440
|
constructor(args) {
|
|
2193
2441
|
this.config = args.config;
|
|
@@ -2195,9 +2443,8 @@ var Mailer = class _Mailer {
|
|
|
2195
2443
|
this.collections = args.collections;
|
|
2196
2444
|
this.adapter = args.adapter;
|
|
2197
2445
|
this.providers = args.providers;
|
|
2198
|
-
this.
|
|
2199
|
-
this.queues = args.queues;
|
|
2200
|
-
this.bullQueues = args.bullQueues;
|
|
2446
|
+
this.queueDriver = args.queueDriver;
|
|
2447
|
+
this.queues = args.queueDriver.queues;
|
|
2201
2448
|
this.events = args.events;
|
|
2202
2449
|
this.runnerContext = {
|
|
2203
2450
|
db: this.db,
|
|
@@ -2262,10 +2509,12 @@ var Mailer = class _Mailer {
|
|
|
2262
2509
|
throw new Error("Mailer.fromEnv: no provider configured (set MAILER_SENDGRID_API_KEY, ...)");
|
|
2263
2510
|
}
|
|
2264
2511
|
const defaultProvider = env.MAILER_DEFAULT_PROVIDER ?? Object.keys(providers)[0];
|
|
2512
|
+
const driverEnv = env.MAILER_QUEUE_DRIVER ?? "bull";
|
|
2513
|
+
const queue = driverEnv === "agenda" ? { driver: "agenda" } : driverEnv === "noop" ? { driver: "noop" } : { driver: "bull", redis: { url: required("MAILER_REDIS_URL") } };
|
|
2265
2514
|
return _Mailer.init({
|
|
2266
2515
|
db,
|
|
2267
2516
|
adapter,
|
|
2268
|
-
|
|
2517
|
+
queue,
|
|
2269
2518
|
providers,
|
|
2270
2519
|
defaultProvider,
|
|
2271
2520
|
publicUrl: required("MAILER_PUBLIC_URL"),
|
|
@@ -2281,19 +2530,9 @@ var Mailer = class _Mailer {
|
|
|
2281
2530
|
}
|
|
2282
2531
|
const collections = getCollections(config.db, config.collectionPrefix);
|
|
2283
2532
|
await ensureIndexes(config.db, config.collectionPrefix);
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
if (config.redis === null) {
|
|
2288
|
-
queues = noopQueues();
|
|
2289
|
-
} else {
|
|
2290
|
-
redis = makeRedis(config.redis);
|
|
2291
|
-
const created = createQueues(redis);
|
|
2292
|
-
queues = created.queues;
|
|
2293
|
-
bullQueues = created.bullQueues;
|
|
2294
|
-
if (!config.workerless) {
|
|
2295
|
-
await scheduleTick(bullQueues, config.tickIntervalSeconds);
|
|
2296
|
-
}
|
|
2533
|
+
const queueDriver = await createQueueDriver(config.queue, config.db);
|
|
2534
|
+
if (!config.workerless && config.queue.driver !== "noop") {
|
|
2535
|
+
await queueDriver.scheduleRepeatingTick(config.tickIntervalSeconds);
|
|
2297
2536
|
}
|
|
2298
2537
|
return new _Mailer({
|
|
2299
2538
|
config,
|
|
@@ -2301,9 +2540,7 @@ var Mailer = class _Mailer {
|
|
|
2301
2540
|
collections,
|
|
2302
2541
|
adapter: config.adapter,
|
|
2303
2542
|
providers: config.providers,
|
|
2304
|
-
|
|
2305
|
-
queues,
|
|
2306
|
-
bullQueues,
|
|
2543
|
+
queueDriver,
|
|
2307
2544
|
events: new EventRegistry()
|
|
2308
2545
|
});
|
|
2309
2546
|
}
|
|
@@ -2611,6 +2848,7 @@ var Mailer = class _Mailer {
|
|
|
2611
2848
|
unsubscribedAt: null,
|
|
2612
2849
|
complainedAt: null,
|
|
2613
2850
|
queuedAt: /* @__PURE__ */ new Date(),
|
|
2851
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
2614
2852
|
sentAt: null,
|
|
2615
2853
|
deliveredAt: null
|
|
2616
2854
|
});
|
|
@@ -2642,14 +2880,16 @@ var Mailer = class _Mailer {
|
|
|
2642
2880
|
// Workers
|
|
2643
2881
|
// -------------------------------------------------------------------------
|
|
2644
2882
|
async startWorkers() {
|
|
2645
|
-
if (this.
|
|
2646
|
-
if (
|
|
2883
|
+
if (this.workersStarted) return;
|
|
2884
|
+
if (this.config.queue.driver === "noop") {
|
|
2885
|
+
throw new Error("startWorkers requires a non-noop queue driver");
|
|
2886
|
+
}
|
|
2647
2887
|
const provider = this.providers[this.config.defaultProvider];
|
|
2648
2888
|
const sendRate = provider?.sendRatePerSecond ?? this.config.sendRatePerSecond;
|
|
2649
|
-
this.
|
|
2650
|
-
redis: this.redis,
|
|
2889
|
+
await this.queueDriver.startWorkers({
|
|
2651
2890
|
concurrency: { send: this.config.sendConcurrency },
|
|
2652
2891
|
sendRateLimit: { max: sendRate, durationMs: 1e3 },
|
|
2892
|
+
retryAttempts: this.config.sendRetryAttempts,
|
|
2653
2893
|
handlers: {
|
|
2654
2894
|
tick: async () => {
|
|
2655
2895
|
await runTick(this.runnerContext);
|
|
@@ -2667,6 +2907,7 @@ var Mailer = class _Mailer {
|
|
|
2667
2907
|
}
|
|
2668
2908
|
}
|
|
2669
2909
|
});
|
|
2910
|
+
this.workersStarted = true;
|
|
2670
2911
|
}
|
|
2671
2912
|
/** Process unprocessed webhook events in mailer_webhook_events. */
|
|
2672
2913
|
async processWebhookBacklog() {
|
|
@@ -2693,20 +2934,8 @@ var Mailer = class _Mailer {
|
|
|
2693
2934
|
}
|
|
2694
2935
|
}
|
|
2695
2936
|
async stop() {
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
this.workers = null;
|
|
2699
|
-
}
|
|
2700
|
-
if (this.bullQueues) {
|
|
2701
|
-
await closeBullQueues(this.bullQueues);
|
|
2702
|
-
this.bullQueues = null;
|
|
2703
|
-
} else {
|
|
2704
|
-
await closeQueues(this.queues);
|
|
2705
|
-
}
|
|
2706
|
-
if (this.redis) {
|
|
2707
|
-
await this.redis.quit().catch(() => {
|
|
2708
|
-
});
|
|
2709
|
-
}
|
|
2937
|
+
await this.queueDriver.close();
|
|
2938
|
+
this.workersStarted = false;
|
|
2710
2939
|
}
|
|
2711
2940
|
/** Used internally by the admin router and tests; not part of the public API. */
|
|
2712
2941
|
getRunnerContext() {
|
|
@@ -2714,6 +2943,43 @@ var Mailer = class _Mailer {
|
|
|
2714
2943
|
}
|
|
2715
2944
|
};
|
|
2716
2945
|
|
|
2946
|
+
// src/server/templates/sender-domain.ts
|
|
2947
|
+
function validateSenderDomain(fromEmail, templateKind, registry) {
|
|
2948
|
+
if (!registry || Object.keys(registry).length === 0) return { ok: true };
|
|
2949
|
+
const domain = extractDomain(fromEmail);
|
|
2950
|
+
if (!domain) {
|
|
2951
|
+
return {
|
|
2952
|
+
ok: false,
|
|
2953
|
+
code: "invalid_email",
|
|
2954
|
+
reason: `invalid fromEmail "${fromEmail}" \u2014 expected "name@domain"`
|
|
2955
|
+
};
|
|
2956
|
+
}
|
|
2957
|
+
const entry = registry[domain];
|
|
2958
|
+
if (!entry) {
|
|
2959
|
+
const known = Object.keys(registry).join(", ");
|
|
2960
|
+
return {
|
|
2961
|
+
ok: false,
|
|
2962
|
+
code: "unregistered_domain",
|
|
2963
|
+
reason: `sender domain "${domain}" is not declared in senderDomains (allowed: ${known})`
|
|
2964
|
+
};
|
|
2965
|
+
}
|
|
2966
|
+
if (entry.kind === "both") return { ok: true };
|
|
2967
|
+
if (entry.kind !== templateKind) {
|
|
2968
|
+
return {
|
|
2969
|
+
ok: false,
|
|
2970
|
+
code: "wrong_kind",
|
|
2971
|
+
reason: `sender domain "${domain}" is configured for ${entry.kind} email, but this template's kind is ${templateKind}`
|
|
2972
|
+
};
|
|
2973
|
+
}
|
|
2974
|
+
return { ok: true };
|
|
2975
|
+
}
|
|
2976
|
+
function extractDomain(email) {
|
|
2977
|
+
if (typeof email !== "string") return null;
|
|
2978
|
+
const at = email.lastIndexOf("@");
|
|
2979
|
+
if (at <= 0 || at === email.length - 1) return null;
|
|
2980
|
+
return email.slice(at + 1).toLowerCase().trim();
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2717
2983
|
// src/server/index.ts
|
|
2718
2984
|
init_mongo();
|
|
2719
2985
|
|
|
@@ -2743,6 +3009,227 @@ var NullProvider = class {
|
|
|
2743
3009
|
|
|
2744
3010
|
// src/server/index.ts
|
|
2745
3011
|
init_sendgrid();
|
|
3012
|
+
|
|
3013
|
+
// src/server/api/setup-status.ts
|
|
3014
|
+
async function runSetupChecks(mailer) {
|
|
3015
|
+
const checks = [];
|
|
3016
|
+
checks.push(await checkMongo(mailer));
|
|
3017
|
+
checks.push(await checkQueue(mailer));
|
|
3018
|
+
if (mailer.config.queue.driver !== "noop" && !mailer.config.workerless) {
|
|
3019
|
+
checks.push(await checkWorkersHeartbeat(mailer));
|
|
3020
|
+
}
|
|
3021
|
+
checks.push(await checkCircuitBreaker(mailer));
|
|
3022
|
+
checks.push(...checkFromDefaultsAgainstRegistry(mailer));
|
|
3023
|
+
checks.push(...await checkPublishedTemplates(mailer));
|
|
3024
|
+
checks.push(await checkPostalAddress(mailer));
|
|
3025
|
+
checks.push(await checkDoiTemplate(mailer));
|
|
3026
|
+
const overall = checks.some((c) => c.severity === "error") ? "error" : checks.some((c) => c.severity === "warn") ? "warn" : "ok";
|
|
3027
|
+
return {
|
|
3028
|
+
overall,
|
|
3029
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3030
|
+
checks
|
|
3031
|
+
};
|
|
3032
|
+
}
|
|
3033
|
+
async function checkMongo(mailer) {
|
|
3034
|
+
try {
|
|
3035
|
+
await mailer.db.admin().ping();
|
|
3036
|
+
return { name: "mongo", label: "MongoDB connection", severity: "ok", message: "reachable" };
|
|
3037
|
+
} catch (err) {
|
|
3038
|
+
return {
|
|
3039
|
+
name: "mongo",
|
|
3040
|
+
label: "MongoDB connection",
|
|
3041
|
+
severity: "error",
|
|
3042
|
+
message: `MongoDB ping failed: ${err?.message ?? err}`,
|
|
3043
|
+
hint: "Mailery is configured against an unreachable Mongo. Sends, flow advancement, and admin reads will all fail."
|
|
3044
|
+
};
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
async function checkQueue(mailer) {
|
|
3048
|
+
const driver = mailer.config.queue.driver;
|
|
3049
|
+
if (driver === "noop") {
|
|
3050
|
+
return {
|
|
3051
|
+
name: "queue",
|
|
3052
|
+
label: "Queue driver",
|
|
3053
|
+
severity: "ok",
|
|
3054
|
+
message: "driver: noop (synchronous-only mode)"
|
|
3055
|
+
};
|
|
3056
|
+
}
|
|
3057
|
+
try {
|
|
3058
|
+
await mailer.queues.send.getWaitingCount();
|
|
3059
|
+
return { name: "queue", label: "Queue driver", severity: "ok", message: `driver: ${driver}` };
|
|
3060
|
+
} catch (err) {
|
|
3061
|
+
return {
|
|
3062
|
+
name: "queue",
|
|
3063
|
+
label: "Queue driver",
|
|
3064
|
+
severity: "error",
|
|
3065
|
+
message: `${driver} queue is not responding: ${err?.message ?? err}`,
|
|
3066
|
+
hint: driver === "bull" ? "Check Redis connectivity (queue.redis.url)." : "Check the @hokify/agenda + Mongo connection."
|
|
3067
|
+
};
|
|
3068
|
+
}
|
|
3069
|
+
}
|
|
3070
|
+
async function checkWorkersHeartbeat(mailer) {
|
|
3071
|
+
const h = await mailer.collections.health.findOne({ _id: "singleton" });
|
|
3072
|
+
const tickIntervalMs = mailer.config.tickIntervalSeconds * 1e3;
|
|
3073
|
+
const staleAfterMs = Math.max(tickIntervalMs * 3, 3e4);
|
|
3074
|
+
if (!h) {
|
|
3075
|
+
return {
|
|
3076
|
+
name: "workers_heartbeat",
|
|
3077
|
+
label: "Background workers",
|
|
3078
|
+
severity: "warn",
|
|
3079
|
+
message: "no tick has run yet",
|
|
3080
|
+
hint: "If your separate worker process is started, the heartbeat will appear within one tick interval. If you forgot to run `mailer.startWorkers()`, sends will sit queued indefinitely."
|
|
3081
|
+
};
|
|
3082
|
+
}
|
|
3083
|
+
const ageMs = Date.now() - new Date(h.updatedAt).getTime();
|
|
3084
|
+
if (ageMs > staleAfterMs) {
|
|
3085
|
+
return {
|
|
3086
|
+
name: "workers_heartbeat",
|
|
3087
|
+
label: "Background workers",
|
|
3088
|
+
severity: "error",
|
|
3089
|
+
message: `last tick ${humanDuration(ageMs)} ago (expected within ${humanDuration(tickIntervalMs)})`,
|
|
3090
|
+
hint: "Workers appear to be down. Sends and flow advancement are halted. Restart your worker process (`mailer.startWorkers()`)."
|
|
3091
|
+
};
|
|
3092
|
+
}
|
|
3093
|
+
return {
|
|
3094
|
+
name: "workers_heartbeat",
|
|
3095
|
+
label: "Background workers",
|
|
3096
|
+
severity: "ok",
|
|
3097
|
+
message: `last tick ${humanDuration(ageMs)} ago`
|
|
3098
|
+
};
|
|
3099
|
+
}
|
|
3100
|
+
async function checkCircuitBreaker(mailer) {
|
|
3101
|
+
const h = await mailer.collections.health.findOne({ _id: "singleton" });
|
|
3102
|
+
if (!h || h.status === "healthy") {
|
|
3103
|
+
return { name: "circuit_breaker", label: "Circuit breaker", severity: "ok", message: "healthy" };
|
|
3104
|
+
}
|
|
3105
|
+
if (h.status === "degraded") {
|
|
3106
|
+
return {
|
|
3107
|
+
name: "circuit_breaker",
|
|
3108
|
+
label: "Circuit breaker",
|
|
3109
|
+
severity: "warn",
|
|
3110
|
+
message: "degraded (high failure rate)",
|
|
3111
|
+
hint: "Marketing sends still flow but failure rate is above the degraded threshold. Investigate provider errors before they escalate to tripped."
|
|
3112
|
+
};
|
|
3113
|
+
}
|
|
3114
|
+
return {
|
|
3115
|
+
name: "circuit_breaker",
|
|
3116
|
+
label: "Circuit breaker",
|
|
3117
|
+
severity: "error",
|
|
3118
|
+
message: `tripped: ${h.trippedReason ?? "unknown reason"}`,
|
|
3119
|
+
hint: "Marketing sends are held. Investigate the underlying bounce / complaint cause, then POST /api/health/resume."
|
|
3120
|
+
};
|
|
3121
|
+
}
|
|
3122
|
+
function checkFromDefaultsAgainstRegistry(mailer) {
|
|
3123
|
+
const registry = mailer.config.senderDomains;
|
|
3124
|
+
if (!registry || Object.keys(registry).length === 0) return [];
|
|
3125
|
+
const out = [];
|
|
3126
|
+
const from = mailer.config.fromDefaults?.email;
|
|
3127
|
+
const tx = mailer.config.transactionalFromDefaults?.email;
|
|
3128
|
+
if (from) {
|
|
3129
|
+
const r = validateSenderDomain(from, "marketing", registry);
|
|
3130
|
+
if (!r.ok) {
|
|
3131
|
+
out.push({
|
|
3132
|
+
name: "from_defaults_marketing",
|
|
3133
|
+
label: "fromDefaults vs senderDomains",
|
|
3134
|
+
severity: "error",
|
|
3135
|
+
message: r.reason,
|
|
3136
|
+
hint: "New marketing templates that fall back to fromDefaults will fail to publish."
|
|
3137
|
+
});
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
3140
|
+
if (tx) {
|
|
3141
|
+
const r = validateSenderDomain(tx, "transactional", registry);
|
|
3142
|
+
if (!r.ok) {
|
|
3143
|
+
out.push({
|
|
3144
|
+
name: "transactional_from_defaults",
|
|
3145
|
+
label: "transactionalFromDefaults vs senderDomains",
|
|
3146
|
+
severity: "error",
|
|
3147
|
+
message: r.reason,
|
|
3148
|
+
hint: "New transactional templates that fall back to transactionalFromDefaults will fail to publish."
|
|
3149
|
+
});
|
|
3150
|
+
}
|
|
3151
|
+
} else if (from) {
|
|
3152
|
+
const r = validateSenderDomain(from, "transactional", registry);
|
|
3153
|
+
if (!r.ok) {
|
|
3154
|
+
out.push({
|
|
3155
|
+
name: "transactional_fallback",
|
|
3156
|
+
label: "Transactional fallback",
|
|
3157
|
+
severity: "warn",
|
|
3158
|
+
message: `transactionalFromDefaults is unset and fromDefaults (${from}) is invalid for transactional templates`,
|
|
3159
|
+
hint: 'Set transactionalFromDefaults to a transactional-kind domain, or set senderDomains entry for the existing one to "both".'
|
|
3160
|
+
});
|
|
3161
|
+
}
|
|
3162
|
+
}
|
|
3163
|
+
return out;
|
|
3164
|
+
}
|
|
3165
|
+
async function checkPublishedTemplates(mailer) {
|
|
3166
|
+
const registry = mailer.config.senderDomains;
|
|
3167
|
+
if (!registry || Object.keys(registry).length === 0) return [];
|
|
3168
|
+
const published = await mailer.collections.templates.find({ publishedAt: { $ne: null } }, { projection: { slug: 1, kind: 1, fromEmail: 1 } }).toArray();
|
|
3169
|
+
const broken = [];
|
|
3170
|
+
for (const tpl of published) {
|
|
3171
|
+
const r = validateSenderDomain(tpl.fromEmail, tpl.kind, registry);
|
|
3172
|
+
if (!r.ok) broken.push({ slug: tpl.slug, reason: r.reason });
|
|
3173
|
+
}
|
|
3174
|
+
if (broken.length === 0) return [];
|
|
3175
|
+
const list = broken.slice(0, 5).map((b) => `${b.slug} (${b.reason})`).join("; ");
|
|
3176
|
+
const more = broken.length > 5 ? ` \u2026and ${broken.length - 5} more` : "";
|
|
3177
|
+
return [
|
|
3178
|
+
{
|
|
3179
|
+
name: "published_template_domains",
|
|
3180
|
+
label: "Published templates",
|
|
3181
|
+
severity: "error",
|
|
3182
|
+
message: `${broken.length} published template${broken.length === 1 ? "" : "s"} use a fromEmail that no longer matches senderDomains: ${list}${more}`,
|
|
3183
|
+
hint: "These templates will still send with their stored fromEmail until re-published. Edit each template and republish to surface the validation, or update senderDomains."
|
|
3184
|
+
}
|
|
3185
|
+
];
|
|
3186
|
+
}
|
|
3187
|
+
async function checkPostalAddress(mailer) {
|
|
3188
|
+
if (mailer.config.senderAddress) {
|
|
3189
|
+
return { name: "postal_address", label: "CAN-SPAM postal address", severity: "ok", message: "set" };
|
|
3190
|
+
}
|
|
3191
|
+
const marketingCount = await mailer.collections.templates.countDocuments({
|
|
3192
|
+
kind: "marketing",
|
|
3193
|
+
publishedAt: { $ne: null }
|
|
3194
|
+
});
|
|
3195
|
+
if (marketingCount === 0) {
|
|
3196
|
+
return { name: "postal_address", label: "CAN-SPAM postal address", severity: "ok", message: "no published marketing templates yet" };
|
|
3197
|
+
}
|
|
3198
|
+
return {
|
|
3199
|
+
name: "postal_address",
|
|
3200
|
+
label: "CAN-SPAM postal address",
|
|
3201
|
+
severity: "warn",
|
|
3202
|
+
message: `${marketingCount} published marketing template${marketingCount === 1 ? "" : "s"} but senderAddress is unset`,
|
|
3203
|
+
hint: "CAN-SPAM requires a postal address in marketing emails. Set `senderAddress` in your Mailer config and reference it via `{{senderAddress}}` in your templates."
|
|
3204
|
+
};
|
|
3205
|
+
}
|
|
3206
|
+
async function checkDoiTemplate(mailer) {
|
|
3207
|
+
if (!mailer.config.requireDoubleOptIn) {
|
|
3208
|
+
return { name: "doi_template", label: "DOI template", severity: "ok", message: "DOI not required" };
|
|
3209
|
+
}
|
|
3210
|
+
const tpl = await mailer.collections.templates.findOne({
|
|
3211
|
+
slug: mailer.config.doiTemplateSlug,
|
|
3212
|
+
publishedAt: { $ne: null }
|
|
3213
|
+
});
|
|
3214
|
+
if (tpl) {
|
|
3215
|
+
return { name: "doi_template", label: "DOI template", severity: "ok", message: `template "${tpl.slug}" published` };
|
|
3216
|
+
}
|
|
3217
|
+
return {
|
|
3218
|
+
name: "doi_template",
|
|
3219
|
+
label: "DOI template",
|
|
3220
|
+
severity: "error",
|
|
3221
|
+
message: `requireDoubleOptIn is true but no published template with slug "${mailer.config.doiTemplateSlug}"`,
|
|
3222
|
+
hint: "New subscriptions will silently fail to send confirmation emails. Create and publish a template with this slug, or unset requireDoubleOptIn."
|
|
3223
|
+
};
|
|
3224
|
+
}
|
|
3225
|
+
function humanDuration(ms) {
|
|
3226
|
+
if (ms < 1e3) return `${ms}ms`;
|
|
3227
|
+
if (ms < 6e4) return `${Math.round(ms / 1e3)}s`;
|
|
3228
|
+
if (ms < 36e5) return `${Math.round(ms / 6e4)}m`;
|
|
3229
|
+
return `${Math.round(ms / 36e5)}h`;
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3232
|
+
// src/server/api/admin.ts
|
|
2746
3233
|
var __filename$1 = url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)));
|
|
2747
3234
|
var __dirname$1 = path__default.default.dirname(__filename$1);
|
|
2748
3235
|
function defaultSpaDir() {
|
|
@@ -2964,6 +3451,13 @@ function apiRouter(mailer) {
|
|
|
2964
3451
|
res.json(rows);
|
|
2965
3452
|
})
|
|
2966
3453
|
);
|
|
3454
|
+
r.get(
|
|
3455
|
+
"/setup-status",
|
|
3456
|
+
asyncHandler(async (_req, res) => {
|
|
3457
|
+
const status = await runSetupChecks(mailer);
|
|
3458
|
+
res.json(status);
|
|
3459
|
+
})
|
|
3460
|
+
);
|
|
2967
3461
|
r.get(
|
|
2968
3462
|
"/health",
|
|
2969
3463
|
asyncHandler(async (_req, res) => {
|
|
@@ -3137,6 +3631,15 @@ function apiRouter(mailer) {
|
|
|
3137
3631
|
if (kind !== "marketing" && kind !== "transactional") {
|
|
3138
3632
|
return res.status(400).json({ error: "validation_failed", message: "kind must be marketing or transactional" });
|
|
3139
3633
|
}
|
|
3634
|
+
const resolvedFromEmail = fromEmail ?? (kind === "transactional" ? mailer.config.transactionalFromDefaults?.email : void 0) ?? mailer.config.fromDefaults?.email ?? "noreply@example.com";
|
|
3635
|
+
const senderCheck = validateSenderDomain(resolvedFromEmail, kind, mailer.config.senderDomains);
|
|
3636
|
+
if (!senderCheck.ok) {
|
|
3637
|
+
return res.status(400).json({
|
|
3638
|
+
error: "sender_domain_invalid",
|
|
3639
|
+
code: senderCheck.code,
|
|
3640
|
+
message: senderCheck.reason
|
|
3641
|
+
});
|
|
3642
|
+
}
|
|
3140
3643
|
const now = /* @__PURE__ */ new Date();
|
|
3141
3644
|
try {
|
|
3142
3645
|
await c.templates.insertOne({
|
|
@@ -3144,8 +3647,8 @@ function apiRouter(mailer) {
|
|
|
3144
3647
|
name,
|
|
3145
3648
|
description: "",
|
|
3146
3649
|
kind,
|
|
3147
|
-
fromName: fromName ?? mailer.config.fromDefaults?.name ?? "Mailery",
|
|
3148
|
-
fromEmail:
|
|
3650
|
+
fromName: fromName ?? (kind === "transactional" ? mailer.config.transactionalFromDefaults?.name : void 0) ?? mailer.config.fromDefaults?.name ?? "Mailery",
|
|
3651
|
+
fromEmail: resolvedFromEmail,
|
|
3149
3652
|
replyTo: null,
|
|
3150
3653
|
providerOverride: null,
|
|
3151
3654
|
subject: subject ?? `Untitled \u2014 ${name}`,
|
|
@@ -3203,6 +3706,18 @@ function apiRouter(mailer) {
|
|
|
3203
3706
|
if (typeof fromEmail === "string") set.fromEmail = fromEmail;
|
|
3204
3707
|
if (typeof replyTo === "string" || replyTo === null) set.replyTo = replyTo;
|
|
3205
3708
|
if (kind === "marketing" || kind === "transactional") set.kind = kind;
|
|
3709
|
+
if (typeof fromEmail === "string" || kind === "marketing" || kind === "transactional") {
|
|
3710
|
+
const resultingKind = set.kind ?? tpl.kind;
|
|
3711
|
+
const resultingFromEmail = set.fromEmail ?? tpl.fromEmail;
|
|
3712
|
+
const senderCheck = validateSenderDomain(resultingFromEmail, resultingKind, mailer.config.senderDomains);
|
|
3713
|
+
if (!senderCheck.ok) {
|
|
3714
|
+
return res.status(400).json({
|
|
3715
|
+
error: "sender_domain_invalid",
|
|
3716
|
+
code: senderCheck.code,
|
|
3717
|
+
message: senderCheck.reason
|
|
3718
|
+
});
|
|
3719
|
+
}
|
|
3720
|
+
}
|
|
3206
3721
|
if (typeof trackOpens === "boolean") set.trackOpens = trackOpens;
|
|
3207
3722
|
if (typeof trackClicks === "boolean") set.trackClicks = trackClicks;
|
|
3208
3723
|
await c.templates.updateOne({ _id: tpl._id }, { $set: set });
|
|
@@ -3221,6 +3736,14 @@ function apiRouter(mailer) {
|
|
|
3221
3736
|
if (!tpl) return res.status(404).json({ error: "not_found" });
|
|
3222
3737
|
const draft = tpl.draft;
|
|
3223
3738
|
if (!draft) return res.status(400).json({ error: "no_draft" });
|
|
3739
|
+
const senderCheck = validateSenderDomain(tpl.fromEmail, tpl.kind, mailer.config.senderDomains);
|
|
3740
|
+
if (!senderCheck.ok) {
|
|
3741
|
+
return res.status(400).json({
|
|
3742
|
+
error: "sender_domain_invalid",
|
|
3743
|
+
code: senderCheck.code,
|
|
3744
|
+
message: senderCheck.reason
|
|
3745
|
+
});
|
|
3746
|
+
}
|
|
3224
3747
|
let compiled;
|
|
3225
3748
|
if (draft.editorJson) {
|
|
3226
3749
|
compiled = await compileMailyTemplate(draft.editorJson);
|
|
@@ -3859,6 +4382,7 @@ exports.runTick = runTick;
|
|
|
3859
4382
|
exports.sha256Hex = sha256Hex;
|
|
3860
4383
|
exports.signUnsubscribeToken = signUnsubscribeToken;
|
|
3861
4384
|
exports.sweepStrandedFlowRuns = sweepStrandedFlowRuns;
|
|
4385
|
+
exports.validateSenderDomain = validateSenderDomain;
|
|
3862
4386
|
exports.verifyUnsubscribeToken = verifyUnsubscribeToken;
|
|
3863
4387
|
//# sourceMappingURL=index.cjs.map
|
|
3864
4388
|
//# sourceMappingURL=index.cjs.map
|