@oxy.so/contracts 4.0.0 → 4.1.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.
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Wire contract for the Inbox read API (`/email/*` on oxy-api).
3
+ *
4
+ * These schemas describe what a client RECEIVES — the JSON-serialised DTOs of
5
+ * `packages/api/src/services/email.service.ts` — so every timestamp is an ISO
6
+ * string and every nullable column is `null`, never absent. oxy-api asserts at
7
+ * the type level that its DTOs serialise to exactly these shapes, and a
8
+ * database-backed test parses real responses with them; a client parses with
9
+ * the same schemas. One declaration, two sides, no drift.
10
+ *
11
+ * Why this exists: the Inbox client used to declare its own copy, with
12
+ * `contentId: z.string().optional()`. oxy-api sends `null` for an attachment
13
+ * without a Content-ID, the client's parse failed, and the list silently
14
+ * dropped the whole message — a verification-code mail that appeared for a
15
+ * second (the realtime placeholder) and then vanished (the refetch).
16
+ *
17
+ * Unknown keys are stripped, not rejected, so the server may ADD a field
18
+ * without breaking an older client. Removing or re-typing one is breaking.
19
+ *
20
+ * Platform-agnostic — zod only, no react/react-native/expo.
21
+ */
22
+ import { z } from 'zod';
23
+ /** An ISO-8601 instant as `Date.prototype.toJSON` writes it. */
24
+ const isoInstant = z.string().datetime();
25
+ // ─── Vocabularies ───────────────────────────────────────────────────
26
+ /** Structured data cards the AI extractor can emit. */
27
+ export const MESSAGE_CARD_TYPES = ['trip', 'purchase', 'event', 'bill', 'package'];
28
+ /** What a mail-rule condition looks at. */
29
+ export const EMAIL_FILTER_CONDITION_FIELDS = ['from', 'to', 'subject', 'has-attachment', 'size'];
30
+ /** How a mail-rule condition compares. */
31
+ export const EMAIL_FILTER_CONDITION_OPERATORS = [
32
+ 'contains',
33
+ 'equals',
34
+ 'not-contains',
35
+ 'starts-with',
36
+ 'ends-with',
37
+ 'greater-than',
38
+ 'less-than',
39
+ ];
40
+ /** What a mail rule does. */
41
+ export const EMAIL_FILTER_ACTION_TYPES = [
42
+ 'move',
43
+ 'label',
44
+ 'star',
45
+ 'mark-read',
46
+ 'archive',
47
+ 'delete',
48
+ 'forward',
49
+ ];
50
+ /** Lifecycle of a durable outbound delivery. */
51
+ export const EMAIL_OUTBOX_STATUSES = ['pending', 'processing', 'sent', 'failed', 'cancelled'];
52
+ // ─── Messages ───────────────────────────────────────────────────────
53
+ /** One addressee. A header without a display name carries `name: ''`. */
54
+ export const emailMessageAddressSchema = z.object({
55
+ name: z.string(),
56
+ address: z.string(),
57
+ });
58
+ /**
59
+ * One attached file. `contentId` is `null` — present, not absent — when the
60
+ * part had no Content-ID, which is most attachments.
61
+ */
62
+ export const emailAttachmentSchema = z.object({
63
+ fileId: z.string(),
64
+ name: z.string(),
65
+ contentType: z.string(),
66
+ size: z.number(),
67
+ contentId: z.string().nullable(),
68
+ isInline: z.boolean(),
69
+ });
70
+ export const emailMessageFlagsSchema = z.object({
71
+ seen: z.boolean(),
72
+ starred: z.boolean(),
73
+ answered: z.boolean(),
74
+ forwarded: z.boolean(),
75
+ draft: z.boolean(),
76
+ pinned: z.boolean(),
77
+ });
78
+ /** The AI-extracted card. Every field but `type` may be unknown. */
79
+ export const emailMessageCardSchema = z.object({
80
+ type: z.enum(MESSAGE_CARD_TYPES),
81
+ data: z.record(z.string(), z.unknown()).nullable(),
82
+ confidence: z.number().nullable(),
83
+ extractedAt: isoInstant.nullable(),
84
+ });
85
+ /** One extracted key/value rendered as a chip. */
86
+ export const emailMessageHighlightSchema = z.object({
87
+ type: z.string(),
88
+ value: z.string(),
89
+ label: z.string(),
90
+ });
91
+ /**
92
+ * A stored message as every `/email` read returns it.
93
+ *
94
+ * `_id` and `id` are the same row id (see the "Wire shapes" note in oxy-api's
95
+ * email service). `messageId` is the RFC 5322 `Message-ID` header — NOT the row
96
+ * id — and it is what `inReplyTo` / `references` of a reply must name.
97
+ */
98
+ export const emailMessageSchema = z.object({
99
+ _id: z.string(),
100
+ id: z.string(),
101
+ userId: z.string(),
102
+ mailboxId: z.string(),
103
+ messageId: z.string(),
104
+ threadId: z.string(),
105
+ from: emailMessageAddressSchema,
106
+ to: z.array(emailMessageAddressSchema),
107
+ cc: z.array(emailMessageAddressSchema),
108
+ bcc: z.array(emailMessageAddressSchema),
109
+ replyTo: emailMessageAddressSchema.optional(),
110
+ subject: z.string(),
111
+ attachments: z.array(emailAttachmentSchema),
112
+ flags: emailMessageFlagsSchema,
113
+ labels: z.array(z.string()),
114
+ card: emailMessageCardSchema.optional(),
115
+ highlights: z.array(emailMessageHighlightSchema),
116
+ encrypted: z.boolean(),
117
+ spamScore: z.number().nullable(),
118
+ spamAction: z.string().nullable(),
119
+ size: z.number(),
120
+ inReplyTo: z.string().nullable(),
121
+ references: z.array(z.string()),
122
+ aliasTag: z.string().nullable(),
123
+ snoozedUntil: isoInstant.nullable(),
124
+ snoozedFromMailbox: z.string().nullable(),
125
+ scheduledAt: isoInstant.nullable(),
126
+ readReceiptRequested: z.boolean(),
127
+ readReceiptSent: z.boolean(),
128
+ date: isoInstant,
129
+ receivedAt: isoInstant,
130
+ createdAt: isoInstant,
131
+ updatedAt: isoInstant,
132
+ draftRevision: z.number().int().min(1),
133
+ /** Present only on the reads that return bodies (single message, thread). */
134
+ text: z.string().nullable().optional(),
135
+ html: z.string().nullable().optional(),
136
+ headers: z.record(z.string(), z.string()).optional(),
137
+ senderAvatarPath: z.string().nullable().optional(),
138
+ /** Present only on list reads that walked the thread. */
139
+ threadCount: z.number().int().optional(),
140
+ threadParticipants: z.array(z.string()).optional(),
141
+ });
142
+ // ─── Mailboxes and labels ───────────────────────────────────────────
143
+ export const emailMailboxSchema = z.object({
144
+ _id: z.string(),
145
+ id: z.string(),
146
+ userId: z.string(),
147
+ name: z.string(),
148
+ path: z.string(),
149
+ specialUse: z.string().nullable(),
150
+ retentionDays: z.number().int().nullable(),
151
+ totalMessages: z.number().int(),
152
+ unseenMessages: z.number().int(),
153
+ size: z.number(),
154
+ createdAt: isoInstant,
155
+ updatedAt: isoInstant,
156
+ });
157
+ /** A label the user made. */
158
+ export const emailUserLabelSchema = z.object({
159
+ _id: z.string(),
160
+ id: z.string(),
161
+ userId: z.string(),
162
+ name: z.string(),
163
+ color: z.string(),
164
+ order: z.number().int(),
165
+ system: z.literal(false),
166
+ createdAt: isoInstant,
167
+ updatedAt: isoInstant,
168
+ });
169
+ /** One of the product's built-in labels; `_id` is `system:<name>`. */
170
+ export const emailSystemLabelSchema = z.object({
171
+ _id: z.string(),
172
+ name: z.string(),
173
+ color: z.string(),
174
+ order: z.number().int(),
175
+ system: z.literal(true),
176
+ });
177
+ export const emailLabelSchema = z.discriminatedUnion('system', [emailUserLabelSchema, emailSystemLabelSchema]);
178
+ // ─── Rules, bundles, contacts, outbox ───────────────────────────────
179
+ export const emailFilterConditionSchema = z.object({
180
+ field: z.enum(EMAIL_FILTER_CONDITION_FIELDS),
181
+ operator: z.enum(EMAIL_FILTER_CONDITION_OPERATORS),
182
+ value: z.string(),
183
+ });
184
+ /** `value` is absent for the actions that take none. */
185
+ export const emailFilterActionSchema = z.object({
186
+ type: z.enum(EMAIL_FILTER_ACTION_TYPES),
187
+ value: z.string().optional(),
188
+ });
189
+ export const emailFilterSchema = z.object({
190
+ _id: z.string(),
191
+ id: z.string(),
192
+ userId: z.string(),
193
+ name: z.string(),
194
+ enabled: z.boolean(),
195
+ matchAll: z.boolean(),
196
+ order: z.number().int(),
197
+ conditions: z.array(emailFilterConditionSchema),
198
+ actions: z.array(emailFilterActionSchema),
199
+ createdAt: isoInstant,
200
+ updatedAt: isoInstant,
201
+ });
202
+ export const emailBundleSchema = z.object({
203
+ _id: z.string(),
204
+ id: z.string(),
205
+ userId: z.string(),
206
+ name: z.string(),
207
+ icon: z.string(),
208
+ color: z.string(),
209
+ matchLabels: z.array(z.string()),
210
+ enabled: z.boolean(),
211
+ collapsed: z.boolean(),
212
+ order: z.number().int(),
213
+ createdAt: isoInstant,
214
+ updatedAt: isoInstant,
215
+ });
216
+ /** `GET /email/messages?bundled=true` — the inbox split into primary and bundles. */
217
+ export const emailBundledInboxSchema = z.object({
218
+ primary: z.array(emailMessageSchema),
219
+ bundles: z.array(z.object({
220
+ bundle: emailBundleSchema,
221
+ messages: z.array(emailMessageSchema),
222
+ unreadCount: z.number().int(),
223
+ })),
224
+ total: z.number().int(),
225
+ });
226
+ /** An address-book entry. `company` and `notes` are `null` when unset. */
227
+ export const emailContactSchema = z.object({
228
+ _id: z.string(),
229
+ id: z.string(),
230
+ userId: z.string(),
231
+ name: z.string(),
232
+ email: z.string(),
233
+ company: z.string().nullable(),
234
+ notes: z.string().nullable(),
235
+ starred: z.boolean(),
236
+ autoCollected: z.boolean(),
237
+ lastContactedAt: isoInstant.nullable(),
238
+ createdAt: isoInstant,
239
+ updatedAt: isoInstant,
240
+ });
241
+ /**
242
+ * One durable outbound delivery. `terminal` means no further attempt will
243
+ * happen on its own — present that differently from "still trying".
244
+ */
245
+ export const emailOutboxSchema = z.object({
246
+ id: z.string(),
247
+ messageId: z.string(),
248
+ status: z.enum(EMAIL_OUTBOX_STATUSES),
249
+ attempts: z.number().int(),
250
+ maxAttempts: z.number().int(),
251
+ terminal: z.boolean(),
252
+ nextAttemptAt: isoInstant,
253
+ lastError: z.string().nullable(),
254
+ sentAt: isoInstant.nullable(),
255
+ createdAt: isoInstant,
256
+ updatedAt: isoInstant,
257
+ });
258
+ // ─── Replies ────────────────────────────────────────────────────────
259
+ /**
260
+ * One RFC 5322 `msg-id`: `<left@right>`, no whitespace, no nested brackets.
261
+ *
262
+ * `In-Reply-To` and `References` carry these and nothing else. A reply that
263
+ * names a database row id instead (`01a0…` or `<01a0…>`) breaks threading for
264
+ * every recipient — it happened, which is why it is refused at the edge.
265
+ */
266
+ export const RFC_MESSAGE_ID_PATTERN = /^<[^<>\s]+@[^<>\s]+>$/;
267
+ export const rfcMessageIdSchema = z
268
+ .string()
269
+ .trim()
270
+ .regex(RFC_MESSAGE_ID_PATTERN, 'Must be an RFC 5322 Message-ID such as <id@host>');
package/dist/esm/index.js CHANGED
@@ -177,3 +177,4 @@ export * from './externalIdentity.js';
177
177
  export * from './linkedAccounts.js';
178
178
  export * from './federationInstanceFetch.js';
179
179
  export * from './notifications.js';
180
+ export * from './email/messages.js';