@remit/mailbox-service 0.0.122 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { type IAddressRepository, type ICalendarStore, type ICalendarSuggestionRepository, type IEnvelopeRepository, type IFilterRepository, type IMailboxSpecialUseRepository, type IMessageRepository, type IThreadMessageRepository } from "@remit/data-ports";
1
+ import { type IAccountRepository, type IAddressRepository, type ICalendarStore, type ICalendarSuggestionRepository, type IEnvelopeRepository, type IFilterRepository, type IMailboxSpecialUseRepository, type IMessageRepository, type IThreadMessageRepository } from "@remit/data-ports";
2
2
  import { type ParsedBody, type StorageService } from "@remit/storage-service";
3
3
  import { type ParsedMail } from "mailparser";
4
4
  import { type FilterConfig } from "./filters/pipeline.js";
@@ -116,6 +116,8 @@ export interface CalendarSuggestionConfig {
116
116
  calendarSuggestionService: ICalendarSuggestionRepository;
117
117
  calendarStore: ICalendarStore;
118
118
  filterService: Pick<IFilterRepository, "listByAccountAndState">;
119
+ /** The account config's addresses: a guest's REPLY lands only on an event the reader organizes. */
120
+ accountService: Pick<IAccountRepository, "listAllByAccountConfig">;
119
121
  }
120
122
  export declare class BodySyncService {
121
123
  private messageService;
@@ -236,10 +238,10 @@ export declare class BodySyncService {
236
238
  * `syncBodies(..., force: true)`) reach this with `bodyStorageKey` already
237
239
  * set, and re-offering there would resurrect a card the user dismissed.
238
240
  *
239
- * Isolated the same way placement and filters are: a card is auxiliary to
240
- * storing the mail, which is already durable by the time this runs. A
241
- * repository failure here is logged loudly with the messageId rather than
242
- * failing a body store that otherwise succeeded.
241
+ * Nothing here is caught. A failure is a fault in this code or its store,
242
+ * so it fails the body-sync job loudly and the job takes the queue's own
243
+ * dead-letter path. A part the sender's client wrote badly is not a failure:
244
+ * it is read as `Unreadable` and offered nothing.
243
245
  */
244
246
  private deriveCalendarSuggestions;
245
247
  private deriveSenderUnsubscribed;
package/dist/body-sync.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { inspect } from "node:util";
2
- import { provisionDefaultCalendar, recordCalendarSuggestion, } from "@remit/calendar-service";
2
+ import { provisionDefaultCalendar, recordCalendarReply, recordCalendarSuggestion, } from "@remit/calendar-service";
3
3
  import { isSenderMuted, } from "@remit/data-ports";
4
4
  import { MessageGoneFromServerError, MessagePlacementUnsettledError, NotFoundError, } from "@remit/data-ports/errors";
5
5
  import { deriveAddressId, deriveBodyPartId } from "@remit/data-ports/id";
@@ -822,10 +822,10 @@ export class BodySyncService {
822
822
  * `syncBodies(..., force: true)`) reach this with `bodyStorageKey` already
823
823
  * set, and re-offering there would resurrect a card the user dismissed.
824
824
  *
825
- * Isolated the same way placement and filters are: a card is auxiliary to
826
- * storing the mail, which is already durable by the time this runs. A
827
- * repository failure here is logged loudly with the messageId rather than
828
- * failing a body store that otherwise succeeded.
825
+ * Nothing here is caught. A failure is a fault in this code or its store,
826
+ * so it fails the body-sync job loudly and the job takes the queue's own
827
+ * dead-letter path. A part the sender's client wrote badly is not a failure:
828
+ * it is read as `Unreadable` and offered nothing.
829
829
  */
830
830
  async deriveCalendarSuggestions(lookups, messageId, accountConfigId, parsed, storedBodyKey) {
831
831
  if (!this.calendarConfig)
@@ -835,49 +835,60 @@ export class BodySyncService {
835
835
  const parts = calendarParts(parsed);
836
836
  if (parts.length === 0)
837
837
  return;
838
- const { calendarStore, calendarSuggestionService } = this.calendarConfig;
839
- await Promise.resolve()
840
- .then(async () => {
841
- // `dismiss{muteSender:true}` wrote a standing rule naming this
842
- // sender. Honouring it here is what makes that button do anything:
843
- // the index-time filter pipeline skips a rule with no label and no
844
- // move, so this is the only reader such a rule has.
845
- const sender = extractPrimaryFromEmail(parsed);
846
- if (sender) {
847
- const active = await lookups.activeFilters();
848
- if (isSenderMuted(active, sender)) {
849
- this.log.debug?.({ messageId, sender }, "Sender is muted; offering no calendar suggestion");
850
- return;
851
- }
838
+ const { calendarStore, calendarSuggestionService, accountService } = this.calendarConfig;
839
+ // A guest answering the reader's own invitation moves their answer on the
840
+ // organizer's copy, whoever the reader muted. Local only: nothing goes
841
+ // back to the guest. A reply proposes nothing to add, so it is never
842
+ // offered as a card.
843
+ const selfAddresses = (await accountService.listAllByAccountConfig(accountConfigId)).map((account) => account.email);
844
+ const offered = [];
845
+ for (const part of parts) {
846
+ const replied = await recordCalendarReply(calendarStore, {
847
+ accountConfigId,
848
+ icalData: part.icalData,
849
+ selfAddresses,
850
+ });
851
+ if (replied === "NotAReply")
852
+ offered.push(part);
853
+ }
854
+ if (offered.length === 0)
855
+ return;
856
+ // `dismiss{muteSender:true}` wrote a standing rule naming this
857
+ // sender. Honouring it here is what makes that button do anything:
858
+ // the index-time filter pipeline skips a rule with no label and no
859
+ // move, so this is the only reader such a rule has.
860
+ const sender = extractPrimaryFromEmail(parsed);
861
+ if (sender) {
862
+ const active = await lookups.activeFilters();
863
+ if (isSenderMuted(active, sender)) {
864
+ this.log.debug?.({ messageId, sender }, "Sender is muted; offering no calendar suggestion");
865
+ return;
852
866
  }
853
- // Provisioned rather than looked up, through the one function every
854
- // caller provisions with. A missing collection has no timezone, and
855
- // a floating DTSTART read in UTC puts the meeting hours from where
856
- // the organizer meant it. Idempotent: the id is derived, so a second
857
- // first use returns the collection the first one made.
858
- const collection = await provisionDefaultCalendar(calendarStore, accountConfigId);
859
- for (const part of parts) {
860
- const recorded = await recordCalendarSuggestion(calendarSuggestionService, {
861
- accountConfigId,
867
+ }
868
+ // Provisioned rather than looked up, through the one function every
869
+ // caller provisions with. A missing collection has no timezone, and
870
+ // a floating DTSTART read in UTC puts the meeting hours from where
871
+ // the organizer meant it. Idempotent: the id is derived, so a second
872
+ // first use returns the collection the first one made.
873
+ const collection = await provisionDefaultCalendar(calendarStore, accountConfigId);
874
+ for (const part of offered) {
875
+ const recorded = await recordCalendarSuggestion(calendarSuggestionService, {
876
+ accountConfigId,
877
+ messageId,
878
+ bodyPartId: deriveBodyPartId(messageId, part.partPath),
879
+ source: part.source,
880
+ icalData: part.icalData,
881
+ timezone: collection.timezone,
882
+ });
883
+ if (!recorded.ok) {
884
+ this.log.info({
862
885
  messageId,
863
- bodyPartId: deriveBodyPartId(messageId, part.partPath),
864
- source: part.source,
865
- icalData: part.icalData,
866
- timezone: collection.timezone,
867
- });
868
- if (!recorded.ok) {
869
- this.log.info({
870
- messageId,
871
- partPath: part.partPath,
872
- code: recorded.error.code,
873
- reason: recorded.error.message,
874
- }, "Message carries iCalendar this cannot read; no suggestion offered");
875
- }
886
+ partPath: part.partPath,
887
+ code: recorded.error.code,
888
+ reason: recorded.error.message,
889
+ }, "Message carries iCalendar this cannot read; no suggestion offered");
876
890
  }
877
- })
878
- .catch((error) => {
879
- this.log.error?.({ messageId, accountConfigId, error: inspect(error) }, "Calendar suggestion derivation failed; body is stored without a card");
880
- });
891
+ }
881
892
  }
882
893
  async deriveSenderUnsubscribed(accountConfigId, fromEmail) {
883
894
  try {
@@ -1,4 +1,4 @@
1
- import type { IAccountRepository, IOutboxMessageRepository, OutboxMessageItem, QueueProducer } from "@remit/data-ports";
1
+ import type { CreateOutboxSchedulingPartInput, IAccountRepository, IOutboxMessageRepository, OutboxMessageItem, QueueProducer } from "@remit/data-ports";
2
2
  import type { OutboxAttachmentService } from "./outbox-attachment.js";
3
3
  export interface SendMessageEvent {
4
4
  type: "SEND_MESSAGE";
@@ -54,6 +54,17 @@ export declare class OutboxQueueService {
54
54
  private refuseWithoutMail;
55
55
  send: (accountConfigId: string, outboxMessageId: string) => Promise<OutboxMessageItem>;
56
56
  createAndSend: (input: CreateDraftInput) => Promise<OutboxMessageItem>;
57
+ /**
58
+ * Queues one calendar scheduling message — an invitation, a cancellation or
59
+ * a reply — with its `text/calendar` part.
60
+ *
61
+ * The row is the pending record and lands first. An enqueue that fails
62
+ * settles it `failed` and answers with that row rather than throwing: the
63
+ * calendar write it follows is already committed, and the person needs the
64
+ * failed send in front of them, with `send` as the retry, not an error that
65
+ * hides which guests were reached.
66
+ */
67
+ createAndSendScheduled: (input: CreateDraftInput, schedulingPart: CreateOutboxSchedulingPartInput) => Promise<OutboxMessageItem>;
57
68
  deleteDraft: (accountConfigId: string, outboxMessageId: string) => Promise<void>;
58
69
  /**
59
70
  * Take a row back out of `queued` after the enqueue reported a failure.
@@ -221,6 +221,38 @@ export class OutboxQueueService {
221
221
  this.log.info({ outboxMessageId: outbox.outboxMessageId, accountId: input.accountId }, "Created and queued outbox message for sending");
222
222
  return outbox;
223
223
  };
224
+ /**
225
+ * Queues one calendar scheduling message — an invitation, a cancellation or
226
+ * a reply — with its `text/calendar` part.
227
+ *
228
+ * The row is the pending record and lands first. An enqueue that fails
229
+ * settles it `failed` and answers with that row rather than throwing: the
230
+ * calendar write it follows is already committed, and the person needs the
231
+ * failed send in front of them, with `send` as the retry, not an error that
232
+ * hides which guests were reached.
233
+ */
234
+ createAndSendScheduled = async (input, schedulingPart) => {
235
+ const domain = extractDomain(input.fromAddress);
236
+ const outbox = await this.outboxMessageService.create({
237
+ accountId: input.accountId,
238
+ accountConfigId: input.accountConfigId,
239
+ fromAddress: input.fromAddress,
240
+ fromName: input.fromName,
241
+ toAddresses: input.toAddresses,
242
+ subject: input.subject,
243
+ textBody: input.textBody,
244
+ messageIdValue: generateMessageId(domain),
245
+ status: OutboxMessageStatus.queued,
246
+ }, schedulingPart);
247
+ const queued = await this.enqueueSend(input.accountId, outbox.outboxMessageId).then(() => true, async (error) => {
248
+ this.log.error({ outboxMessageId: outbox.outboxMessageId, error: String(error) }, "The queue refused a calendar scheduling message; settling it failed");
249
+ await this.settleUnqueued(input.accountConfigId, outbox.outboxMessageId, OutboxMessageStatus.failed);
250
+ return false;
251
+ });
252
+ if (queued)
253
+ return outbox;
254
+ return this.outboxMessageService.get(input.accountConfigId, outbox.outboxMessageId);
255
+ };
224
256
  deleteDraft = async (accountConfigId, outboxMessageId) => {
225
257
  const existing = await this.outboxMessageService.get(accountConfigId, outboxMessageId, "act");
226
258
  // `unfiled` is discardable: the message was delivered but never landed in
@@ -16,6 +16,7 @@ export declare const openStore: () => Promise<{
16
16
  message: import("@remit/drizzle-service").DrizzleMessageRepository;
17
17
  messageFlag: import("@remit/drizzle-service").DrizzleMessageFlagRepository;
18
18
  outboxMessage: import("@remit/drizzle-service").OutboxMessageRepo;
19
+ outboxSchedulingPart: import("@remit/drizzle-service").OutboxSchedulingPartRepo;
19
20
  outboxAttachment: import("@remit/drizzle-service").OutboxAttachmentRepo;
20
21
  threadMessage: import("@remit/drizzle-service").DrizzleThreadMessageRepository;
21
22
  envelope: import("@remit/drizzle-service").DrizzleEnvelopeRepository;
@@ -34,6 +35,7 @@ export declare const openStore: () => Promise<{
34
35
  calendarObject: import("@remit/drizzle-service").CalendarObjectRepo;
35
36
  calendarEventIndex: import("@remit/drizzle-service").CalendarEventIndexRepo;
36
37
  calendarFeedToken: import("@remit/drizzle-service").CalendarFeedTokenRepo;
38
+ calendarTask: import("@remit/drizzle-service").CalendarTaskRepo;
37
39
  calendarSuggestion: import("@remit/drizzle-service").CalendarSuggestionRepo;
38
40
  calendarStore: import("@remit/drizzle-service").DrizzleCalendarStore;
39
41
  }>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/mailbox-service",
3
- "version": "0.0.122",
3
+ "version": "1.0.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",