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.js
CHANGED
|
@@ -2,7 +2,6 @@ import { ObjectId } from 'mongodb';
|
|
|
2
2
|
import crypto2 from 'crypto';
|
|
3
3
|
import sgMail from '@sendgrid/mail';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
-
import { Queue, Worker } from 'bullmq';
|
|
6
5
|
import IORedis from 'ioredis';
|
|
7
6
|
import Handlebars from 'handlebars';
|
|
8
7
|
import { convert } from 'html-to-text';
|
|
@@ -568,7 +567,8 @@ async function ensureIndexes(db, prefix = "mailer_") {
|
|
|
568
567
|
{ key: { flowRunId: 1 }, sparse: true },
|
|
569
568
|
{ key: { broadcastId: 1 }, sparse: true },
|
|
570
569
|
{ key: { providerMessageId: 1 }, sparse: true },
|
|
571
|
-
{ key: { status: 1, queuedAt: 1 } }
|
|
570
|
+
{ key: { status: 1, queuedAt: 1 } },
|
|
571
|
+
{ key: { status: 1, updatedAt: 1 } }
|
|
572
572
|
]),
|
|
573
573
|
c.suppressions.createIndexes([
|
|
574
574
|
{ key: { email: 1, scope: 1 }, unique: true, partialFilterExpression: { email: { $type: "string" } } },
|
|
@@ -715,6 +715,104 @@ function verifyDoiToken(token, secret, now = /* @__PURE__ */ new Date()) {
|
|
|
715
715
|
if (body.x < now.getTime()) return null;
|
|
716
716
|
return { externalId: body.i, expiresAt: new Date(body.x) };
|
|
717
717
|
}
|
|
718
|
+
var QUEUE_NAMES = {
|
|
719
|
+
tick: "mailer-tick",
|
|
720
|
+
advance: "mailer-advance",
|
|
721
|
+
send: "mailer-send",
|
|
722
|
+
webhook: "mailer-webhook"
|
|
723
|
+
};
|
|
724
|
+
var BullDriver = class _BullDriver {
|
|
725
|
+
queues;
|
|
726
|
+
redis;
|
|
727
|
+
bullQueues;
|
|
728
|
+
workers = null;
|
|
729
|
+
bull;
|
|
730
|
+
static async create(redisConfig) {
|
|
731
|
+
let bull;
|
|
732
|
+
try {
|
|
733
|
+
bull = await import('bullmq');
|
|
734
|
+
} catch {
|
|
735
|
+
throw new Error(
|
|
736
|
+
"mailery: queue driver 'bull' requires the 'bullmq' peer dependency. Run `npm install bullmq ioredis`."
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
const redis = isRedisLike(redisConfig) ? redisConfig : connect(redisConfig);
|
|
740
|
+
return new _BullDriver(bull, redis);
|
|
741
|
+
}
|
|
742
|
+
constructor(bull, redis) {
|
|
743
|
+
this.bull = bull;
|
|
744
|
+
this.redis = redis;
|
|
745
|
+
const opts = { connection: redis };
|
|
746
|
+
this.bullQueues = {
|
|
747
|
+
tick: new bull.Queue(QUEUE_NAMES.tick, opts),
|
|
748
|
+
advance: new bull.Queue(QUEUE_NAMES.advance, opts),
|
|
749
|
+
send: new bull.Queue(QUEUE_NAMES.send, opts),
|
|
750
|
+
webhook: new bull.Queue(QUEUE_NAMES.webhook, opts)
|
|
751
|
+
};
|
|
752
|
+
this.queues = {
|
|
753
|
+
tick: adaptBullQueue(this.bullQueues.tick),
|
|
754
|
+
advance: adaptBullQueue(this.bullQueues.advance),
|
|
755
|
+
send: adaptBullQueue(this.bullQueues.send),
|
|
756
|
+
webhook: adaptBullQueue(this.bullQueues.webhook)
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
async scheduleRepeatingTick(intervalSeconds) {
|
|
760
|
+
await this.bullQueues.tick.upsertJobScheduler(
|
|
761
|
+
"mailer-tick-repeat",
|
|
762
|
+
{ every: intervalSeconds * 1e3 },
|
|
763
|
+
{ name: "tick", data: {} }
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
async startWorkers(opts) {
|
|
767
|
+
if (this.workers) return;
|
|
768
|
+
const base = { connection: this.redis };
|
|
769
|
+
const { Worker } = this.bull;
|
|
770
|
+
const tick = new Worker(
|
|
771
|
+
QUEUE_NAMES.tick,
|
|
772
|
+
async (job) => opts.handlers.tick(job.data),
|
|
773
|
+
{ ...base, concurrency: 1 }
|
|
774
|
+
);
|
|
775
|
+
const advance = new Worker(
|
|
776
|
+
QUEUE_NAMES.advance,
|
|
777
|
+
async (job) => opts.handlers.advance(job.data),
|
|
778
|
+
{ ...base, concurrency: 10 }
|
|
779
|
+
);
|
|
780
|
+
const send = new Worker(
|
|
781
|
+
QUEUE_NAMES.send,
|
|
782
|
+
async (job) => opts.handlers.send(job.data),
|
|
783
|
+
{
|
|
784
|
+
...base,
|
|
785
|
+
concurrency: opts.concurrency.send,
|
|
786
|
+
limiter: opts.sendRateLimit ? { max: opts.sendRateLimit.max, duration: opts.sendRateLimit.durationMs } : void 0
|
|
787
|
+
}
|
|
788
|
+
);
|
|
789
|
+
const webhook = new Worker(
|
|
790
|
+
QUEUE_NAMES.webhook,
|
|
791
|
+
async (job) => opts.handlers.webhook(job.data),
|
|
792
|
+
{ ...base, concurrency: 4 }
|
|
793
|
+
);
|
|
794
|
+
this.workers = { tick, advance, send, webhook };
|
|
795
|
+
}
|
|
796
|
+
async stopWorkers() {
|
|
797
|
+
if (!this.workers) return;
|
|
798
|
+
await Promise.all([
|
|
799
|
+
this.workers.tick.close(),
|
|
800
|
+
this.workers.advance.close(),
|
|
801
|
+
this.workers.send.close(),
|
|
802
|
+
this.workers.webhook.close()
|
|
803
|
+
]);
|
|
804
|
+
this.workers = null;
|
|
805
|
+
}
|
|
806
|
+
async close() {
|
|
807
|
+
await this.stopWorkers();
|
|
808
|
+
await Promise.all([
|
|
809
|
+
this.bullQueues.tick.close(),
|
|
810
|
+
this.bullQueues.advance.close(),
|
|
811
|
+
this.bullQueues.send.close(),
|
|
812
|
+
this.bullQueues.webhook.close()
|
|
813
|
+
]);
|
|
814
|
+
}
|
|
815
|
+
};
|
|
718
816
|
function adaptBullQueue(q) {
|
|
719
817
|
return {
|
|
720
818
|
add: (name, data, opts) => q.add(name, data, opts),
|
|
@@ -722,39 +820,16 @@ function adaptBullQueue(q) {
|
|
|
722
820
|
close: () => q.close()
|
|
723
821
|
};
|
|
724
822
|
}
|
|
725
|
-
function
|
|
726
|
-
return
|
|
727
|
-
add: async () => void 0,
|
|
728
|
-
getWaitingCount: async () => 0,
|
|
729
|
-
close: async () => void 0
|
|
730
|
-
};
|
|
731
|
-
}
|
|
732
|
-
function noopQueues() {
|
|
733
|
-
return {
|
|
734
|
-
tick: noopQueueAPI(),
|
|
735
|
-
advance: noopQueueAPI(),
|
|
736
|
-
send: noopQueueAPI(),
|
|
737
|
-
webhook: noopQueueAPI()
|
|
738
|
-
};
|
|
739
|
-
}
|
|
740
|
-
function namespacedQueueNames(prefix) {
|
|
741
|
-
return {
|
|
742
|
-
tick: "mailer-tick",
|
|
743
|
-
advance: "mailer-advance",
|
|
744
|
-
send: "mailer-send",
|
|
745
|
-
webhook: "mailer-webhook"
|
|
746
|
-
};
|
|
823
|
+
function isRedisLike(x) {
|
|
824
|
+
return !!x && typeof x === "object" && typeof x.get === "function" && typeof x.set === "function";
|
|
747
825
|
}
|
|
748
|
-
function
|
|
749
|
-
if (isRedisLike(opts)) return opts;
|
|
826
|
+
function connect(opts) {
|
|
750
827
|
const config = {
|
|
751
828
|
maxRetriesPerRequest: null,
|
|
752
829
|
// BullMQ requirement
|
|
753
830
|
enableReadyCheck: false
|
|
754
831
|
};
|
|
755
|
-
if (opts.url)
|
|
756
|
-
return new IORedis(opts.url, config);
|
|
757
|
-
}
|
|
832
|
+
if (opts.url) return new IORedis(opts.url, config);
|
|
758
833
|
return new IORedis({
|
|
759
834
|
...config,
|
|
760
835
|
host: opts.host ?? "127.0.0.1",
|
|
@@ -765,69 +840,198 @@ function makeRedis(opts) {
|
|
|
765
840
|
tls: opts.tls ? {} : void 0
|
|
766
841
|
});
|
|
767
842
|
}
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
843
|
+
|
|
844
|
+
// src/server/queues/agenda.ts
|
|
845
|
+
var QUEUE_NAMES2 = {
|
|
846
|
+
tick: "mailer-tick",
|
|
847
|
+
advance: "mailer-advance",
|
|
848
|
+
send: "mailer-send",
|
|
849
|
+
webhook: "mailer-webhook"
|
|
850
|
+
};
|
|
851
|
+
var AgendaDriver = class _AgendaDriver {
|
|
852
|
+
queues;
|
|
853
|
+
agenda;
|
|
854
|
+
agendaMod;
|
|
855
|
+
sendLimiter = null;
|
|
856
|
+
started = false;
|
|
857
|
+
static async create(opts) {
|
|
858
|
+
let agendaMod;
|
|
859
|
+
let backendMod;
|
|
860
|
+
try {
|
|
861
|
+
agendaMod = await import('agenda');
|
|
862
|
+
backendMod = await import('@agendajs/mongo-backend');
|
|
863
|
+
} catch {
|
|
864
|
+
throw new Error(
|
|
865
|
+
"mailery: queue driver 'agenda' requires the 'agenda' and '@agendajs/mongo-backend' peer dependencies. Run `npm install agenda @agendajs/mongo-backend bottleneck`."
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
const backend = new backendMod.MongoBackend({
|
|
869
|
+
mongo: opts.db,
|
|
870
|
+
collection: opts.collectionName ?? "_mailerJobs"
|
|
871
|
+
});
|
|
872
|
+
const agenda = new agendaMod.Agenda({
|
|
873
|
+
backend,
|
|
874
|
+
processEvery: `${opts.processEverySeconds ?? 5} seconds`,
|
|
875
|
+
defaultLockLifetime: (opts.lockLifetimeSeconds ?? 10 * 60) * 1e3,
|
|
876
|
+
maxConcurrency: 50,
|
|
877
|
+
defaultConcurrency: 5
|
|
878
|
+
});
|
|
879
|
+
return new _AgendaDriver(agenda, agendaMod, opts.db);
|
|
880
|
+
}
|
|
881
|
+
db;
|
|
882
|
+
constructor(agenda, agendaMod, db) {
|
|
883
|
+
this.agenda = agenda;
|
|
884
|
+
this.agendaMod = agendaMod;
|
|
885
|
+
this.db = db;
|
|
886
|
+
this.queues = {
|
|
887
|
+
tick: this.makeQueueAPI(QUEUE_NAMES2.tick),
|
|
888
|
+
advance: this.makeQueueAPI(QUEUE_NAMES2.advance),
|
|
889
|
+
send: this.makeQueueAPI(QUEUE_NAMES2.send),
|
|
890
|
+
webhook: this.makeQueueAPI(QUEUE_NAMES2.webhook)
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
makeQueueAPI(name) {
|
|
894
|
+
return {
|
|
895
|
+
add: async (_jobName, data, opts) => {
|
|
896
|
+
const payload = { ...data };
|
|
897
|
+
if (opts?.jobId) {
|
|
898
|
+
payload.__jobId = opts.jobId;
|
|
899
|
+
if (await this.findPending(name, opts.jobId)) return;
|
|
900
|
+
}
|
|
901
|
+
const job = this.agenda.create(name, payload);
|
|
902
|
+
if (opts?.delay) job.schedule(new Date(Date.now() + opts.delay));
|
|
903
|
+
await job.save();
|
|
904
|
+
},
|
|
905
|
+
getWaitingCount: async () => {
|
|
906
|
+
return this.jobsCollection().countDocuments({
|
|
907
|
+
name,
|
|
908
|
+
$or: [{ lockedAt: null }, { lockedAt: { $exists: false } }],
|
|
909
|
+
nextRunAt: { $lte: /* @__PURE__ */ new Date() }
|
|
910
|
+
});
|
|
911
|
+
},
|
|
912
|
+
close: async () => {
|
|
913
|
+
}
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
/** Direct access to the Mongo collection Agenda persists jobs into. */
|
|
917
|
+
jobsCollection() {
|
|
918
|
+
return this.db.collection(this.collectionName());
|
|
919
|
+
}
|
|
920
|
+
collectionName() {
|
|
921
|
+
return "_mailerJobs";
|
|
922
|
+
}
|
|
923
|
+
async findPending(name, jobId) {
|
|
924
|
+
return this.jobsCollection().findOne({
|
|
925
|
+
name,
|
|
926
|
+
"data.__jobId": jobId,
|
|
927
|
+
$or: [{ lastFinishedAt: null }, { lastFinishedAt: { $exists: false } }]
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
async scheduleRepeatingTick(intervalSeconds) {
|
|
931
|
+
if (!this.started) {
|
|
932
|
+
this.agenda.define(QUEUE_NAMES2.tick, async () => {
|
|
933
|
+
}, { concurrency: 1 });
|
|
934
|
+
await this.agenda.start();
|
|
935
|
+
this.started = true;
|
|
936
|
+
}
|
|
937
|
+
await this.agenda.every(`${intervalSeconds} seconds`, QUEUE_NAMES2.tick);
|
|
938
|
+
}
|
|
939
|
+
async startWorkers(opts) {
|
|
940
|
+
const exp = this.agendaMod.backoffStrategies.exponential;
|
|
941
|
+
if (opts.sendRateLimit) {
|
|
942
|
+
try {
|
|
943
|
+
const Bottleneck = (await import('bottleneck')).default;
|
|
944
|
+
this.sendLimiter = new Bottleneck({
|
|
945
|
+
minTime: Math.ceil(opts.sendRateLimit.durationMs / opts.sendRateLimit.max),
|
|
946
|
+
maxConcurrent: opts.concurrency.send
|
|
947
|
+
});
|
|
948
|
+
} catch {
|
|
949
|
+
throw new Error(
|
|
950
|
+
"mailery: queue driver 'agenda' with sendRateLimit requires the 'bottleneck' peer dependency. Run `npm install bottleneck`."
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
const retryBackoff = exp({ delay: 6e4, maxRetries: Math.max(0, opts.retryAttempts - 1), factor: 2 });
|
|
955
|
+
this.agenda.define(QUEUE_NAMES2.tick, async (job) => {
|
|
956
|
+
await opts.handlers.tick(job.attrs.data);
|
|
957
|
+
}, { concurrency: 1 });
|
|
958
|
+
this.agenda.define(QUEUE_NAMES2.advance, async (job) => {
|
|
959
|
+
await opts.handlers.advance(job.attrs.data);
|
|
960
|
+
}, { concurrency: 10, backoff: retryBackoff });
|
|
961
|
+
this.agenda.define(QUEUE_NAMES2.send, async (job) => {
|
|
962
|
+
const data = job.attrs.data;
|
|
963
|
+
if (this.sendLimiter) {
|
|
964
|
+
await this.sendLimiter.schedule(() => opts.handlers.send(data));
|
|
965
|
+
} else {
|
|
966
|
+
await opts.handlers.send(data);
|
|
967
|
+
}
|
|
968
|
+
}, { concurrency: opts.concurrency.send, backoff: retryBackoff });
|
|
969
|
+
this.agenda.define(QUEUE_NAMES2.webhook, async (job) => {
|
|
970
|
+
await opts.handlers.webhook(job.attrs.data);
|
|
971
|
+
}, { concurrency: 4, backoff: retryBackoff });
|
|
972
|
+
if (!this.started) {
|
|
973
|
+
await this.agenda.start();
|
|
974
|
+
this.started = true;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
async stopWorkers() {
|
|
978
|
+
if (!this.started) return;
|
|
979
|
+
await this.agenda.stop();
|
|
980
|
+
this.started = false;
|
|
981
|
+
if (this.sendLimiter) {
|
|
982
|
+
await this.sendLimiter.stop({ dropWaitingJobs: true }).catch(() => {
|
|
983
|
+
});
|
|
984
|
+
this.sendLimiter = null;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
async close() {
|
|
988
|
+
await this.stopWorkers();
|
|
989
|
+
}
|
|
990
|
+
};
|
|
991
|
+
|
|
992
|
+
// src/server/queues/noop.ts
|
|
993
|
+
function noopQueueAPI() {
|
|
780
994
|
return {
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
send: adaptBullQueue(bullQueues.send),
|
|
785
|
-
webhook: adaptBullQueue(bullQueues.webhook)
|
|
786
|
-
},
|
|
787
|
-
bullQueues
|
|
995
|
+
add: async () => void 0,
|
|
996
|
+
getWaitingCount: async () => 0,
|
|
997
|
+
close: async () => void 0
|
|
788
998
|
};
|
|
789
999
|
}
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
}
|
|
797
|
-
function createWorkers(input) {
|
|
798
|
-
const names = namespacedQueueNames();
|
|
799
|
-
const base = { connection: input.redis };
|
|
800
|
-
const tick = new Worker(names.tick, async (job) => input.handlers.tick(job.data), {
|
|
801
|
-
...base,
|
|
802
|
-
concurrency: 1
|
|
803
|
-
// single tick driver per worker process
|
|
804
|
-
});
|
|
805
|
-
const advance = new Worker(
|
|
806
|
-
names.advance,
|
|
807
|
-
async (job) => input.handlers.advance(job.data),
|
|
808
|
-
{ ...base, concurrency: 10 }
|
|
809
|
-
);
|
|
810
|
-
const sendOpts = {
|
|
811
|
-
...base,
|
|
812
|
-
concurrency: input.concurrency.send,
|
|
813
|
-
limiter: input.sendRateLimit ? { max: input.sendRateLimit.max, duration: input.sendRateLimit.durationMs } : void 0
|
|
1000
|
+
var NoopDriver = class {
|
|
1001
|
+
queues = {
|
|
1002
|
+
tick: noopQueueAPI(),
|
|
1003
|
+
advance: noopQueueAPI(),
|
|
1004
|
+
send: noopQueueAPI(),
|
|
1005
|
+
webhook: noopQueueAPI()
|
|
814
1006
|
};
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
async function
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
1007
|
+
async scheduleRepeatingTick(_intervalSeconds) {
|
|
1008
|
+
}
|
|
1009
|
+
async startWorkers(_opts) {
|
|
1010
|
+
}
|
|
1011
|
+
async stopWorkers() {
|
|
1012
|
+
}
|
|
1013
|
+
async close() {
|
|
1014
|
+
}
|
|
1015
|
+
};
|
|
1016
|
+
|
|
1017
|
+
// src/server/queues/index.ts
|
|
1018
|
+
async function createQueueDriver(config, fallbackDb) {
|
|
1019
|
+
switch (config.driver) {
|
|
1020
|
+
case "bull":
|
|
1021
|
+
return BullDriver.create(config.redis);
|
|
1022
|
+
case "agenda":
|
|
1023
|
+
return AgendaDriver.create({
|
|
1024
|
+
db: config.db ?? fallbackDb,
|
|
1025
|
+
processEverySeconds: config.processEverySeconds,
|
|
1026
|
+
lockLifetimeSeconds: config.lockLifetimeSeconds,
|
|
1027
|
+
collectionName: config.collectionName
|
|
1028
|
+
});
|
|
1029
|
+
case "noop":
|
|
1030
|
+
return new NoopDriver();
|
|
1031
|
+
default: {
|
|
1032
|
+
throw new Error(`mailery: unknown queue driver`);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
831
1035
|
}
|
|
832
1036
|
|
|
833
1037
|
// src/server/runner/triggers.ts
|
|
@@ -1320,7 +1524,8 @@ async function dispatchSend(sendId, ctx) {
|
|
|
1320
1524
|
status: "sending",
|
|
1321
1525
|
fromName: rendered.fromName,
|
|
1322
1526
|
fromEmail: rendered.fromEmail,
|
|
1323
|
-
subject: rendered.subject
|
|
1527
|
+
subject: rendered.subject,
|
|
1528
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1324
1529
|
}
|
|
1325
1530
|
}
|
|
1326
1531
|
);
|
|
@@ -1394,6 +1599,7 @@ function buildRenderContext(contact, run, vars, ctx) {
|
|
|
1394
1599
|
};
|
|
1395
1600
|
}
|
|
1396
1601
|
function newSendDoc(input) {
|
|
1602
|
+
const now = /* @__PURE__ */ new Date();
|
|
1397
1603
|
return {
|
|
1398
1604
|
_id: input._id,
|
|
1399
1605
|
dedupeKey: input.dedupeKey,
|
|
@@ -1424,7 +1630,8 @@ function newSendDoc(input) {
|
|
|
1424
1630
|
clickedLinks: [],
|
|
1425
1631
|
unsubscribedAt: null,
|
|
1426
1632
|
complainedAt: null,
|
|
1427
|
-
queuedAt:
|
|
1633
|
+
queuedAt: now,
|
|
1634
|
+
updatedAt: now,
|
|
1428
1635
|
sentAt: null,
|
|
1429
1636
|
deliveredAt: null
|
|
1430
1637
|
};
|
|
@@ -1906,6 +2113,7 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
|
|
|
1906
2113
|
unsubscribedAt: null,
|
|
1907
2114
|
complainedAt: null,
|
|
1908
2115
|
queuedAt: /* @__PURE__ */ new Date(),
|
|
2116
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
1909
2117
|
sentAt: null,
|
|
1910
2118
|
deliveredAt: null
|
|
1911
2119
|
};
|
|
@@ -1978,13 +2186,36 @@ async function promoteSoftBounces(ctx) {
|
|
|
1978
2186
|
}
|
|
1979
2187
|
|
|
1980
2188
|
// src/server/runner/tick.ts
|
|
2189
|
+
var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
1981
2190
|
async function runTick(ctx) {
|
|
2191
|
+
await ctx.collections.health.updateOne(
|
|
2192
|
+
{ _id: "singleton" },
|
|
2193
|
+
{
|
|
2194
|
+
$set: { updatedAt: /* @__PURE__ */ new Date() },
|
|
2195
|
+
$setOnInsert: {
|
|
2196
|
+
_id: "singleton",
|
|
2197
|
+
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
2198
|
+
windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
|
|
2199
|
+
status: "healthy",
|
|
2200
|
+
trippedAt: null,
|
|
2201
|
+
trippedReason: null,
|
|
2202
|
+
manuallyResumedAt: null,
|
|
2203
|
+
counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
|
|
2204
|
+
rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
|
|
2205
|
+
}
|
|
2206
|
+
},
|
|
2207
|
+
{ upsert: true }
|
|
2208
|
+
).catch(() => {
|
|
2209
|
+
});
|
|
1982
2210
|
await processNewlyFiredEventTriggers(ctx).catch((err) => {
|
|
1983
2211
|
console.error("mailery: triggers scan failed", err);
|
|
1984
2212
|
});
|
|
1985
2213
|
await sweepStrandedFlowRuns(ctx).catch((err) => {
|
|
1986
2214
|
console.error("mailery: sweep failed", err);
|
|
1987
2215
|
});
|
|
2216
|
+
await sweepStrandedSends(ctx).catch((err) => {
|
|
2217
|
+
console.error("mailery: stranded-send sweep failed", err);
|
|
2218
|
+
});
|
|
1988
2219
|
await drainOutbox(ctx).catch((err) => {
|
|
1989
2220
|
console.error("mailery: outbox drain failed", err);
|
|
1990
2221
|
});
|
|
@@ -1998,6 +2229,24 @@ async function runTick(ctx) {
|
|
|
1998
2229
|
console.error("mailery: soft-bounce promotion failed", err);
|
|
1999
2230
|
});
|
|
2000
2231
|
}
|
|
2232
|
+
async function sweepStrandedSends(ctx) {
|
|
2233
|
+
const cutoff = new Date(Date.now() - STRANDED_SEND_THRESHOLD_MS);
|
|
2234
|
+
const cursor = ctx.collections.sends.find(
|
|
2235
|
+
{ status: "sending", updatedAt: { $lt: cutoff } },
|
|
2236
|
+
{ projection: { _id: 1 } }
|
|
2237
|
+
).limit(500);
|
|
2238
|
+
for await (const row of cursor) {
|
|
2239
|
+
const reset = await ctx.collections.sends.updateOne(
|
|
2240
|
+
{ _id: row._id, status: "sending", updatedAt: { $lt: cutoff } },
|
|
2241
|
+
{ $set: { status: "queued", updatedAt: /* @__PURE__ */ new Date() } }
|
|
2242
|
+
);
|
|
2243
|
+
if (reset.modifiedCount === 0) continue;
|
|
2244
|
+
await ctx.queues.send.add("send", { sendId: String(row._id) }, {
|
|
2245
|
+
attempts: ctx.config.sendRetryAttempts,
|
|
2246
|
+
backoff: { type: "exponential", delay: 6e4 }
|
|
2247
|
+
});
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2001
2250
|
async function drainOutbox(ctx) {
|
|
2002
2251
|
const batch = await ctx.collections.outbox.find({ status: "pending" }).sort({ enqueuedAt: 1 }).limit(200).toArray();
|
|
2003
2252
|
for (const row of batch) {
|
|
@@ -2168,12 +2417,11 @@ var Mailer = class _Mailer {
|
|
|
2168
2417
|
collections;
|
|
2169
2418
|
adapter;
|
|
2170
2419
|
providers;
|
|
2171
|
-
redis;
|
|
2172
2420
|
queues;
|
|
2173
2421
|
config;
|
|
2174
2422
|
events;
|
|
2175
|
-
|
|
2176
|
-
|
|
2423
|
+
queueDriver;
|
|
2424
|
+
workersStarted = false;
|
|
2177
2425
|
runnerContext;
|
|
2178
2426
|
constructor(args) {
|
|
2179
2427
|
this.config = args.config;
|
|
@@ -2181,9 +2429,8 @@ var Mailer = class _Mailer {
|
|
|
2181
2429
|
this.collections = args.collections;
|
|
2182
2430
|
this.adapter = args.adapter;
|
|
2183
2431
|
this.providers = args.providers;
|
|
2184
|
-
this.
|
|
2185
|
-
this.queues = args.queues;
|
|
2186
|
-
this.bullQueues = args.bullQueues;
|
|
2432
|
+
this.queueDriver = args.queueDriver;
|
|
2433
|
+
this.queues = args.queueDriver.queues;
|
|
2187
2434
|
this.events = args.events;
|
|
2188
2435
|
this.runnerContext = {
|
|
2189
2436
|
db: this.db,
|
|
@@ -2248,10 +2495,12 @@ var Mailer = class _Mailer {
|
|
|
2248
2495
|
throw new Error("Mailer.fromEnv: no provider configured (set MAILER_SENDGRID_API_KEY, ...)");
|
|
2249
2496
|
}
|
|
2250
2497
|
const defaultProvider = env.MAILER_DEFAULT_PROVIDER ?? Object.keys(providers)[0];
|
|
2498
|
+
const driverEnv = env.MAILER_QUEUE_DRIVER ?? "bull";
|
|
2499
|
+
const queue = driverEnv === "agenda" ? { driver: "agenda" } : driverEnv === "noop" ? { driver: "noop" } : { driver: "bull", redis: { url: required("MAILER_REDIS_URL") } };
|
|
2251
2500
|
return _Mailer.init({
|
|
2252
2501
|
db,
|
|
2253
2502
|
adapter,
|
|
2254
|
-
|
|
2503
|
+
queue,
|
|
2255
2504
|
providers,
|
|
2256
2505
|
defaultProvider,
|
|
2257
2506
|
publicUrl: required("MAILER_PUBLIC_URL"),
|
|
@@ -2267,19 +2516,9 @@ var Mailer = class _Mailer {
|
|
|
2267
2516
|
}
|
|
2268
2517
|
const collections = getCollections(config.db, config.collectionPrefix);
|
|
2269
2518
|
await ensureIndexes(config.db, config.collectionPrefix);
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
if (config.redis === null) {
|
|
2274
|
-
queues = noopQueues();
|
|
2275
|
-
} else {
|
|
2276
|
-
redis = makeRedis(config.redis);
|
|
2277
|
-
const created = createQueues(redis);
|
|
2278
|
-
queues = created.queues;
|
|
2279
|
-
bullQueues = created.bullQueues;
|
|
2280
|
-
if (!config.workerless) {
|
|
2281
|
-
await scheduleTick(bullQueues, config.tickIntervalSeconds);
|
|
2282
|
-
}
|
|
2519
|
+
const queueDriver = await createQueueDriver(config.queue, config.db);
|
|
2520
|
+
if (!config.workerless && config.queue.driver !== "noop") {
|
|
2521
|
+
await queueDriver.scheduleRepeatingTick(config.tickIntervalSeconds);
|
|
2283
2522
|
}
|
|
2284
2523
|
return new _Mailer({
|
|
2285
2524
|
config,
|
|
@@ -2287,9 +2526,7 @@ var Mailer = class _Mailer {
|
|
|
2287
2526
|
collections,
|
|
2288
2527
|
adapter: config.adapter,
|
|
2289
2528
|
providers: config.providers,
|
|
2290
|
-
|
|
2291
|
-
queues,
|
|
2292
|
-
bullQueues,
|
|
2529
|
+
queueDriver,
|
|
2293
2530
|
events: new EventRegistry()
|
|
2294
2531
|
});
|
|
2295
2532
|
}
|
|
@@ -2597,6 +2834,7 @@ var Mailer = class _Mailer {
|
|
|
2597
2834
|
unsubscribedAt: null,
|
|
2598
2835
|
complainedAt: null,
|
|
2599
2836
|
queuedAt: /* @__PURE__ */ new Date(),
|
|
2837
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
2600
2838
|
sentAt: null,
|
|
2601
2839
|
deliveredAt: null
|
|
2602
2840
|
});
|
|
@@ -2628,14 +2866,16 @@ var Mailer = class _Mailer {
|
|
|
2628
2866
|
// Workers
|
|
2629
2867
|
// -------------------------------------------------------------------------
|
|
2630
2868
|
async startWorkers() {
|
|
2631
|
-
if (this.
|
|
2632
|
-
if (
|
|
2869
|
+
if (this.workersStarted) return;
|
|
2870
|
+
if (this.config.queue.driver === "noop") {
|
|
2871
|
+
throw new Error("startWorkers requires a non-noop queue driver");
|
|
2872
|
+
}
|
|
2633
2873
|
const provider = this.providers[this.config.defaultProvider];
|
|
2634
2874
|
const sendRate = provider?.sendRatePerSecond ?? this.config.sendRatePerSecond;
|
|
2635
|
-
this.
|
|
2636
|
-
redis: this.redis,
|
|
2875
|
+
await this.queueDriver.startWorkers({
|
|
2637
2876
|
concurrency: { send: this.config.sendConcurrency },
|
|
2638
2877
|
sendRateLimit: { max: sendRate, durationMs: 1e3 },
|
|
2878
|
+
retryAttempts: this.config.sendRetryAttempts,
|
|
2639
2879
|
handlers: {
|
|
2640
2880
|
tick: async () => {
|
|
2641
2881
|
await runTick(this.runnerContext);
|
|
@@ -2653,6 +2893,7 @@ var Mailer = class _Mailer {
|
|
|
2653
2893
|
}
|
|
2654
2894
|
}
|
|
2655
2895
|
});
|
|
2896
|
+
this.workersStarted = true;
|
|
2656
2897
|
}
|
|
2657
2898
|
/** Process unprocessed webhook events in mailer_webhook_events. */
|
|
2658
2899
|
async processWebhookBacklog() {
|
|
@@ -2679,20 +2920,8 @@ var Mailer = class _Mailer {
|
|
|
2679
2920
|
}
|
|
2680
2921
|
}
|
|
2681
2922
|
async stop() {
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
this.workers = null;
|
|
2685
|
-
}
|
|
2686
|
-
if (this.bullQueues) {
|
|
2687
|
-
await closeBullQueues(this.bullQueues);
|
|
2688
|
-
this.bullQueues = null;
|
|
2689
|
-
} else {
|
|
2690
|
-
await closeQueues(this.queues);
|
|
2691
|
-
}
|
|
2692
|
-
if (this.redis) {
|
|
2693
|
-
await this.redis.quit().catch(() => {
|
|
2694
|
-
});
|
|
2695
|
-
}
|
|
2923
|
+
await this.queueDriver.close();
|
|
2924
|
+
this.workersStarted = false;
|
|
2696
2925
|
}
|
|
2697
2926
|
/** Used internally by the admin router and tests; not part of the public API. */
|
|
2698
2927
|
getRunnerContext() {
|
|
@@ -2700,6 +2929,43 @@ var Mailer = class _Mailer {
|
|
|
2700
2929
|
}
|
|
2701
2930
|
};
|
|
2702
2931
|
|
|
2932
|
+
// src/server/templates/sender-domain.ts
|
|
2933
|
+
function validateSenderDomain(fromEmail, templateKind, registry) {
|
|
2934
|
+
if (!registry || Object.keys(registry).length === 0) return { ok: true };
|
|
2935
|
+
const domain = extractDomain(fromEmail);
|
|
2936
|
+
if (!domain) {
|
|
2937
|
+
return {
|
|
2938
|
+
ok: false,
|
|
2939
|
+
code: "invalid_email",
|
|
2940
|
+
reason: `invalid fromEmail "${fromEmail}" \u2014 expected "name@domain"`
|
|
2941
|
+
};
|
|
2942
|
+
}
|
|
2943
|
+
const entry = registry[domain];
|
|
2944
|
+
if (!entry) {
|
|
2945
|
+
const known = Object.keys(registry).join(", ");
|
|
2946
|
+
return {
|
|
2947
|
+
ok: false,
|
|
2948
|
+
code: "unregistered_domain",
|
|
2949
|
+
reason: `sender domain "${domain}" is not declared in senderDomains (allowed: ${known})`
|
|
2950
|
+
};
|
|
2951
|
+
}
|
|
2952
|
+
if (entry.kind === "both") return { ok: true };
|
|
2953
|
+
if (entry.kind !== templateKind) {
|
|
2954
|
+
return {
|
|
2955
|
+
ok: false,
|
|
2956
|
+
code: "wrong_kind",
|
|
2957
|
+
reason: `sender domain "${domain}" is configured for ${entry.kind} email, but this template's kind is ${templateKind}`
|
|
2958
|
+
};
|
|
2959
|
+
}
|
|
2960
|
+
return { ok: true };
|
|
2961
|
+
}
|
|
2962
|
+
function extractDomain(email) {
|
|
2963
|
+
if (typeof email !== "string") return null;
|
|
2964
|
+
const at = email.lastIndexOf("@");
|
|
2965
|
+
if (at <= 0 || at === email.length - 1) return null;
|
|
2966
|
+
return email.slice(at + 1).toLowerCase().trim();
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2703
2969
|
// src/server/index.ts
|
|
2704
2970
|
init_mongo();
|
|
2705
2971
|
|
|
@@ -2729,6 +2995,227 @@ var NullProvider = class {
|
|
|
2729
2995
|
|
|
2730
2996
|
// src/server/index.ts
|
|
2731
2997
|
init_sendgrid();
|
|
2998
|
+
|
|
2999
|
+
// src/server/api/setup-status.ts
|
|
3000
|
+
async function runSetupChecks(mailer) {
|
|
3001
|
+
const checks = [];
|
|
3002
|
+
checks.push(await checkMongo(mailer));
|
|
3003
|
+
checks.push(await checkQueue(mailer));
|
|
3004
|
+
if (mailer.config.queue.driver !== "noop" && !mailer.config.workerless) {
|
|
3005
|
+
checks.push(await checkWorkersHeartbeat(mailer));
|
|
3006
|
+
}
|
|
3007
|
+
checks.push(await checkCircuitBreaker(mailer));
|
|
3008
|
+
checks.push(...checkFromDefaultsAgainstRegistry(mailer));
|
|
3009
|
+
checks.push(...await checkPublishedTemplates(mailer));
|
|
3010
|
+
checks.push(await checkPostalAddress(mailer));
|
|
3011
|
+
checks.push(await checkDoiTemplate(mailer));
|
|
3012
|
+
const overall = checks.some((c) => c.severity === "error") ? "error" : checks.some((c) => c.severity === "warn") ? "warn" : "ok";
|
|
3013
|
+
return {
|
|
3014
|
+
overall,
|
|
3015
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3016
|
+
checks
|
|
3017
|
+
};
|
|
3018
|
+
}
|
|
3019
|
+
async function checkMongo(mailer) {
|
|
3020
|
+
try {
|
|
3021
|
+
await mailer.db.admin().ping();
|
|
3022
|
+
return { name: "mongo", label: "MongoDB connection", severity: "ok", message: "reachable" };
|
|
3023
|
+
} catch (err) {
|
|
3024
|
+
return {
|
|
3025
|
+
name: "mongo",
|
|
3026
|
+
label: "MongoDB connection",
|
|
3027
|
+
severity: "error",
|
|
3028
|
+
message: `MongoDB ping failed: ${err?.message ?? err}`,
|
|
3029
|
+
hint: "Mailery is configured against an unreachable Mongo. Sends, flow advancement, and admin reads will all fail."
|
|
3030
|
+
};
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
async function checkQueue(mailer) {
|
|
3034
|
+
const driver = mailer.config.queue.driver;
|
|
3035
|
+
if (driver === "noop") {
|
|
3036
|
+
return {
|
|
3037
|
+
name: "queue",
|
|
3038
|
+
label: "Queue driver",
|
|
3039
|
+
severity: "ok",
|
|
3040
|
+
message: "driver: noop (synchronous-only mode)"
|
|
3041
|
+
};
|
|
3042
|
+
}
|
|
3043
|
+
try {
|
|
3044
|
+
await mailer.queues.send.getWaitingCount();
|
|
3045
|
+
return { name: "queue", label: "Queue driver", severity: "ok", message: `driver: ${driver}` };
|
|
3046
|
+
} catch (err) {
|
|
3047
|
+
return {
|
|
3048
|
+
name: "queue",
|
|
3049
|
+
label: "Queue driver",
|
|
3050
|
+
severity: "error",
|
|
3051
|
+
message: `${driver} queue is not responding: ${err?.message ?? err}`,
|
|
3052
|
+
hint: driver === "bull" ? "Check Redis connectivity (queue.redis.url)." : "Check the @hokify/agenda + Mongo connection."
|
|
3053
|
+
};
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
async function checkWorkersHeartbeat(mailer) {
|
|
3057
|
+
const h = await mailer.collections.health.findOne({ _id: "singleton" });
|
|
3058
|
+
const tickIntervalMs = mailer.config.tickIntervalSeconds * 1e3;
|
|
3059
|
+
const staleAfterMs = Math.max(tickIntervalMs * 3, 3e4);
|
|
3060
|
+
if (!h) {
|
|
3061
|
+
return {
|
|
3062
|
+
name: "workers_heartbeat",
|
|
3063
|
+
label: "Background workers",
|
|
3064
|
+
severity: "warn",
|
|
3065
|
+
message: "no tick has run yet",
|
|
3066
|
+
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."
|
|
3067
|
+
};
|
|
3068
|
+
}
|
|
3069
|
+
const ageMs = Date.now() - new Date(h.updatedAt).getTime();
|
|
3070
|
+
if (ageMs > staleAfterMs) {
|
|
3071
|
+
return {
|
|
3072
|
+
name: "workers_heartbeat",
|
|
3073
|
+
label: "Background workers",
|
|
3074
|
+
severity: "error",
|
|
3075
|
+
message: `last tick ${humanDuration(ageMs)} ago (expected within ${humanDuration(tickIntervalMs)})`,
|
|
3076
|
+
hint: "Workers appear to be down. Sends and flow advancement are halted. Restart your worker process (`mailer.startWorkers()`)."
|
|
3077
|
+
};
|
|
3078
|
+
}
|
|
3079
|
+
return {
|
|
3080
|
+
name: "workers_heartbeat",
|
|
3081
|
+
label: "Background workers",
|
|
3082
|
+
severity: "ok",
|
|
3083
|
+
message: `last tick ${humanDuration(ageMs)} ago`
|
|
3084
|
+
};
|
|
3085
|
+
}
|
|
3086
|
+
async function checkCircuitBreaker(mailer) {
|
|
3087
|
+
const h = await mailer.collections.health.findOne({ _id: "singleton" });
|
|
3088
|
+
if (!h || h.status === "healthy") {
|
|
3089
|
+
return { name: "circuit_breaker", label: "Circuit breaker", severity: "ok", message: "healthy" };
|
|
3090
|
+
}
|
|
3091
|
+
if (h.status === "degraded") {
|
|
3092
|
+
return {
|
|
3093
|
+
name: "circuit_breaker",
|
|
3094
|
+
label: "Circuit breaker",
|
|
3095
|
+
severity: "warn",
|
|
3096
|
+
message: "degraded (high failure rate)",
|
|
3097
|
+
hint: "Marketing sends still flow but failure rate is above the degraded threshold. Investigate provider errors before they escalate to tripped."
|
|
3098
|
+
};
|
|
3099
|
+
}
|
|
3100
|
+
return {
|
|
3101
|
+
name: "circuit_breaker",
|
|
3102
|
+
label: "Circuit breaker",
|
|
3103
|
+
severity: "error",
|
|
3104
|
+
message: `tripped: ${h.trippedReason ?? "unknown reason"}`,
|
|
3105
|
+
hint: "Marketing sends are held. Investigate the underlying bounce / complaint cause, then POST /api/health/resume."
|
|
3106
|
+
};
|
|
3107
|
+
}
|
|
3108
|
+
function checkFromDefaultsAgainstRegistry(mailer) {
|
|
3109
|
+
const registry = mailer.config.senderDomains;
|
|
3110
|
+
if (!registry || Object.keys(registry).length === 0) return [];
|
|
3111
|
+
const out = [];
|
|
3112
|
+
const from = mailer.config.fromDefaults?.email;
|
|
3113
|
+
const tx = mailer.config.transactionalFromDefaults?.email;
|
|
3114
|
+
if (from) {
|
|
3115
|
+
const r = validateSenderDomain(from, "marketing", registry);
|
|
3116
|
+
if (!r.ok) {
|
|
3117
|
+
out.push({
|
|
3118
|
+
name: "from_defaults_marketing",
|
|
3119
|
+
label: "fromDefaults vs senderDomains",
|
|
3120
|
+
severity: "error",
|
|
3121
|
+
message: r.reason,
|
|
3122
|
+
hint: "New marketing templates that fall back to fromDefaults will fail to publish."
|
|
3123
|
+
});
|
|
3124
|
+
}
|
|
3125
|
+
}
|
|
3126
|
+
if (tx) {
|
|
3127
|
+
const r = validateSenderDomain(tx, "transactional", registry);
|
|
3128
|
+
if (!r.ok) {
|
|
3129
|
+
out.push({
|
|
3130
|
+
name: "transactional_from_defaults",
|
|
3131
|
+
label: "transactionalFromDefaults vs senderDomains",
|
|
3132
|
+
severity: "error",
|
|
3133
|
+
message: r.reason,
|
|
3134
|
+
hint: "New transactional templates that fall back to transactionalFromDefaults will fail to publish."
|
|
3135
|
+
});
|
|
3136
|
+
}
|
|
3137
|
+
} else if (from) {
|
|
3138
|
+
const r = validateSenderDomain(from, "transactional", registry);
|
|
3139
|
+
if (!r.ok) {
|
|
3140
|
+
out.push({
|
|
3141
|
+
name: "transactional_fallback",
|
|
3142
|
+
label: "Transactional fallback",
|
|
3143
|
+
severity: "warn",
|
|
3144
|
+
message: `transactionalFromDefaults is unset and fromDefaults (${from}) is invalid for transactional templates`,
|
|
3145
|
+
hint: 'Set transactionalFromDefaults to a transactional-kind domain, or set senderDomains entry for the existing one to "both".'
|
|
3146
|
+
});
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
3149
|
+
return out;
|
|
3150
|
+
}
|
|
3151
|
+
async function checkPublishedTemplates(mailer) {
|
|
3152
|
+
const registry = mailer.config.senderDomains;
|
|
3153
|
+
if (!registry || Object.keys(registry).length === 0) return [];
|
|
3154
|
+
const published = await mailer.collections.templates.find({ publishedAt: { $ne: null } }, { projection: { slug: 1, kind: 1, fromEmail: 1 } }).toArray();
|
|
3155
|
+
const broken = [];
|
|
3156
|
+
for (const tpl of published) {
|
|
3157
|
+
const r = validateSenderDomain(tpl.fromEmail, tpl.kind, registry);
|
|
3158
|
+
if (!r.ok) broken.push({ slug: tpl.slug, reason: r.reason });
|
|
3159
|
+
}
|
|
3160
|
+
if (broken.length === 0) return [];
|
|
3161
|
+
const list = broken.slice(0, 5).map((b) => `${b.slug} (${b.reason})`).join("; ");
|
|
3162
|
+
const more = broken.length > 5 ? ` \u2026and ${broken.length - 5} more` : "";
|
|
3163
|
+
return [
|
|
3164
|
+
{
|
|
3165
|
+
name: "published_template_domains",
|
|
3166
|
+
label: "Published templates",
|
|
3167
|
+
severity: "error",
|
|
3168
|
+
message: `${broken.length} published template${broken.length === 1 ? "" : "s"} use a fromEmail that no longer matches senderDomains: ${list}${more}`,
|
|
3169
|
+
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."
|
|
3170
|
+
}
|
|
3171
|
+
];
|
|
3172
|
+
}
|
|
3173
|
+
async function checkPostalAddress(mailer) {
|
|
3174
|
+
if (mailer.config.senderAddress) {
|
|
3175
|
+
return { name: "postal_address", label: "CAN-SPAM postal address", severity: "ok", message: "set" };
|
|
3176
|
+
}
|
|
3177
|
+
const marketingCount = await mailer.collections.templates.countDocuments({
|
|
3178
|
+
kind: "marketing",
|
|
3179
|
+
publishedAt: { $ne: null }
|
|
3180
|
+
});
|
|
3181
|
+
if (marketingCount === 0) {
|
|
3182
|
+
return { name: "postal_address", label: "CAN-SPAM postal address", severity: "ok", message: "no published marketing templates yet" };
|
|
3183
|
+
}
|
|
3184
|
+
return {
|
|
3185
|
+
name: "postal_address",
|
|
3186
|
+
label: "CAN-SPAM postal address",
|
|
3187
|
+
severity: "warn",
|
|
3188
|
+
message: `${marketingCount} published marketing template${marketingCount === 1 ? "" : "s"} but senderAddress is unset`,
|
|
3189
|
+
hint: "CAN-SPAM requires a postal address in marketing emails. Set `senderAddress` in your Mailer config and reference it via `{{senderAddress}}` in your templates."
|
|
3190
|
+
};
|
|
3191
|
+
}
|
|
3192
|
+
async function checkDoiTemplate(mailer) {
|
|
3193
|
+
if (!mailer.config.requireDoubleOptIn) {
|
|
3194
|
+
return { name: "doi_template", label: "DOI template", severity: "ok", message: "DOI not required" };
|
|
3195
|
+
}
|
|
3196
|
+
const tpl = await mailer.collections.templates.findOne({
|
|
3197
|
+
slug: mailer.config.doiTemplateSlug,
|
|
3198
|
+
publishedAt: { $ne: null }
|
|
3199
|
+
});
|
|
3200
|
+
if (tpl) {
|
|
3201
|
+
return { name: "doi_template", label: "DOI template", severity: "ok", message: `template "${tpl.slug}" published` };
|
|
3202
|
+
}
|
|
3203
|
+
return {
|
|
3204
|
+
name: "doi_template",
|
|
3205
|
+
label: "DOI template",
|
|
3206
|
+
severity: "error",
|
|
3207
|
+
message: `requireDoubleOptIn is true but no published template with slug "${mailer.config.doiTemplateSlug}"`,
|
|
3208
|
+
hint: "New subscriptions will silently fail to send confirmation emails. Create and publish a template with this slug, or unset requireDoubleOptIn."
|
|
3209
|
+
};
|
|
3210
|
+
}
|
|
3211
|
+
function humanDuration(ms) {
|
|
3212
|
+
if (ms < 1e3) return `${ms}ms`;
|
|
3213
|
+
if (ms < 6e4) return `${Math.round(ms / 1e3)}s`;
|
|
3214
|
+
if (ms < 36e5) return `${Math.round(ms / 6e4)}m`;
|
|
3215
|
+
return `${Math.round(ms / 36e5)}h`;
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3218
|
+
// src/server/api/admin.ts
|
|
2732
3219
|
var __filename$1 = fileURLToPath(import.meta.url);
|
|
2733
3220
|
var __dirname$1 = path.dirname(__filename$1);
|
|
2734
3221
|
function defaultSpaDir() {
|
|
@@ -2950,6 +3437,13 @@ function apiRouter(mailer) {
|
|
|
2950
3437
|
res.json(rows);
|
|
2951
3438
|
})
|
|
2952
3439
|
);
|
|
3440
|
+
r.get(
|
|
3441
|
+
"/setup-status",
|
|
3442
|
+
asyncHandler(async (_req, res) => {
|
|
3443
|
+
const status = await runSetupChecks(mailer);
|
|
3444
|
+
res.json(status);
|
|
3445
|
+
})
|
|
3446
|
+
);
|
|
2953
3447
|
r.get(
|
|
2954
3448
|
"/health",
|
|
2955
3449
|
asyncHandler(async (_req, res) => {
|
|
@@ -3123,6 +3617,15 @@ function apiRouter(mailer) {
|
|
|
3123
3617
|
if (kind !== "marketing" && kind !== "transactional") {
|
|
3124
3618
|
return res.status(400).json({ error: "validation_failed", message: "kind must be marketing or transactional" });
|
|
3125
3619
|
}
|
|
3620
|
+
const resolvedFromEmail = fromEmail ?? (kind === "transactional" ? mailer.config.transactionalFromDefaults?.email : void 0) ?? mailer.config.fromDefaults?.email ?? "noreply@example.com";
|
|
3621
|
+
const senderCheck = validateSenderDomain(resolvedFromEmail, kind, mailer.config.senderDomains);
|
|
3622
|
+
if (!senderCheck.ok) {
|
|
3623
|
+
return res.status(400).json({
|
|
3624
|
+
error: "sender_domain_invalid",
|
|
3625
|
+
code: senderCheck.code,
|
|
3626
|
+
message: senderCheck.reason
|
|
3627
|
+
});
|
|
3628
|
+
}
|
|
3126
3629
|
const now = /* @__PURE__ */ new Date();
|
|
3127
3630
|
try {
|
|
3128
3631
|
await c.templates.insertOne({
|
|
@@ -3130,8 +3633,8 @@ function apiRouter(mailer) {
|
|
|
3130
3633
|
name,
|
|
3131
3634
|
description: "",
|
|
3132
3635
|
kind,
|
|
3133
|
-
fromName: fromName ?? mailer.config.fromDefaults?.name ?? "Mailery",
|
|
3134
|
-
fromEmail:
|
|
3636
|
+
fromName: fromName ?? (kind === "transactional" ? mailer.config.transactionalFromDefaults?.name : void 0) ?? mailer.config.fromDefaults?.name ?? "Mailery",
|
|
3637
|
+
fromEmail: resolvedFromEmail,
|
|
3135
3638
|
replyTo: null,
|
|
3136
3639
|
providerOverride: null,
|
|
3137
3640
|
subject: subject ?? `Untitled \u2014 ${name}`,
|
|
@@ -3189,6 +3692,18 @@ function apiRouter(mailer) {
|
|
|
3189
3692
|
if (typeof fromEmail === "string") set.fromEmail = fromEmail;
|
|
3190
3693
|
if (typeof replyTo === "string" || replyTo === null) set.replyTo = replyTo;
|
|
3191
3694
|
if (kind === "marketing" || kind === "transactional") set.kind = kind;
|
|
3695
|
+
if (typeof fromEmail === "string" || kind === "marketing" || kind === "transactional") {
|
|
3696
|
+
const resultingKind = set.kind ?? tpl.kind;
|
|
3697
|
+
const resultingFromEmail = set.fromEmail ?? tpl.fromEmail;
|
|
3698
|
+
const senderCheck = validateSenderDomain(resultingFromEmail, resultingKind, mailer.config.senderDomains);
|
|
3699
|
+
if (!senderCheck.ok) {
|
|
3700
|
+
return res.status(400).json({
|
|
3701
|
+
error: "sender_domain_invalid",
|
|
3702
|
+
code: senderCheck.code,
|
|
3703
|
+
message: senderCheck.reason
|
|
3704
|
+
});
|
|
3705
|
+
}
|
|
3706
|
+
}
|
|
3192
3707
|
if (typeof trackOpens === "boolean") set.trackOpens = trackOpens;
|
|
3193
3708
|
if (typeof trackClicks === "boolean") set.trackClicks = trackClicks;
|
|
3194
3709
|
await c.templates.updateOne({ _id: tpl._id }, { $set: set });
|
|
@@ -3207,6 +3722,14 @@ function apiRouter(mailer) {
|
|
|
3207
3722
|
if (!tpl) return res.status(404).json({ error: "not_found" });
|
|
3208
3723
|
const draft = tpl.draft;
|
|
3209
3724
|
if (!draft) return res.status(400).json({ error: "no_draft" });
|
|
3725
|
+
const senderCheck = validateSenderDomain(tpl.fromEmail, tpl.kind, mailer.config.senderDomains);
|
|
3726
|
+
if (!senderCheck.ok) {
|
|
3727
|
+
return res.status(400).json({
|
|
3728
|
+
error: "sender_domain_invalid",
|
|
3729
|
+
code: senderCheck.code,
|
|
3730
|
+
message: senderCheck.reason
|
|
3731
|
+
});
|
|
3732
|
+
}
|
|
3210
3733
|
let compiled;
|
|
3211
3734
|
if (draft.editorJson) {
|
|
3212
3735
|
compiled = await compileMailyTemplate(draft.editorJson);
|
|
@@ -3817,6 +4340,6 @@ var DEDUPE_POLICIES = [
|
|
|
3817
4340
|
// src/server/index.ts
|
|
3818
4341
|
var VERSION = "0.1.0";
|
|
3819
4342
|
|
|
3820
|
-
export { DEDUPE_POLICIES, FLOW_STEP_KINDS, Mailer, MongoContactAdapter, NullProvider, PREDICATE_KINDS, SEGMENT_FILTER_KINDS, SendGridProvider, VERSION, applyTracking, applyWebhookEvent, compileMailyTemplate, compileTemplate, createAdminRouter, createPublicRouter, defaultFlowStep, defaultPredicate, defaultSegmentFilter, derivePlaintext, dispatchSend, ensureIndexes, getCollections, predicateKind, processNewlyFiredEventTriggers, processOneRunStep, renderTemplate, runTick, sha256Hex, signUnsubscribeToken, sweepStrandedFlowRuns, verifyUnsubscribeToken };
|
|
4343
|
+
export { DEDUPE_POLICIES, FLOW_STEP_KINDS, Mailer, MongoContactAdapter, NullProvider, PREDICATE_KINDS, SEGMENT_FILTER_KINDS, SendGridProvider, VERSION, applyTracking, applyWebhookEvent, compileMailyTemplate, compileTemplate, createAdminRouter, createPublicRouter, defaultFlowStep, defaultPredicate, defaultSegmentFilter, derivePlaintext, dispatchSend, ensureIndexes, getCollections, predicateKind, processNewlyFiredEventTriggers, processOneRunStep, renderTemplate, runTick, sha256Hex, signUnsubscribeToken, sweepStrandedFlowRuns, validateSenderDomain, verifyUnsubscribeToken };
|
|
3821
4344
|
//# sourceMappingURL=index.js.map
|
|
3822
4345
|
//# sourceMappingURL=index.js.map
|