@bobfrankston/mailx-types 0.1.34 → 0.1.38

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/index.d.ts CHANGED
@@ -8,6 +8,10 @@ export type { MailxApi } from "./mailx-api.js";
8
8
  export { addContactsDenylistEntry, addContactsPreferredEntry, type PreferredContactEntry, type CloudReadFn, type CloudWriteFn, } from "./contacts-config.js";
9
9
  export { expandRecipients, splitRecipients, isAddressToken, extractAddress, } from "./groups.js";
10
10
  export type { GroupMap, RecipientToken, ExpansionResult } from "./groups.js";
11
+ export { REMINDER_STATE_FILE, normalizeReminderState, mergeReminderStates, reminderStatesEqual, } from "./reminder-state.js";
12
+ export type { ReminderState } from "./reminder-state.js";
13
+ export { extractInlineImages } from "./mime-inline-images.js";
14
+ export type { InlineImagePart } from "./mime-inline-images.js";
11
15
  /** Supported authentication methods */
12
16
  export type AuthMethod = "password" | "oauth2";
13
17
  /** Mail account configuration */
package/index.js CHANGED
@@ -14,6 +14,12 @@ export { addContactsDenylistEntry, addContactsPreferredEntry, } from "./contacts
14
14
  // send time. Both desktop and Android send paths consume this expander
15
15
  // against contacts.jsonc → groups.
16
16
  export { expandRecipients, splitRecipients, isAddressToken, extractAddress, } from "./groups.js";
17
+ // Reminder dismissed/snoozed cross-device state — one merge implementation
18
+ // for the desktop service (mailx-settings) and Android/web (mailx-store-web).
19
+ export { REMINDER_STATE_FILE, normalizeReminderState, mergeReminderStates, reminderStatesEqual, } from "./reminder-state.js";
20
+ // Outgoing-mail inline images: data: URI → cid: extraction shared by both
21
+ // MIME assemblers (desktop send + Android/web send).
22
+ export { extractInlineImages } from "./mime-inline-images.js";
17
23
  // ── Message flag state ──
18
24
  //
19
25
  // External API surface for the IMAP system flags. The literal strings
package/mailx-api.d.ts CHANGED
@@ -213,6 +213,20 @@ export interface MailxApi {
213
213
  dataBase64?: string;
214
214
  mime?: string;
215
215
  }>;
216
+ /** Cross-device reminder dismissed/snoozed state (reminders.jsonc on the
217
+ * shared Drive folder). merge sends the device's local maps and returns
218
+ * the merged union — push + pull in one round-trip. */
219
+ getReminderState?(): Promise<{
220
+ dismissed: Record<string, number>;
221
+ snoozed: Record<string, number>;
222
+ }>;
223
+ mergeReminderState?(patch: {
224
+ dismissed: Record<string, number>;
225
+ snoozed: Record<string, number>;
226
+ }): Promise<{
227
+ dismissed: Record<string, number>;
228
+ snoozed: Record<string, number>;
229
+ }>;
216
230
  logClientEvent?(...args: any[]): void | Promise<void>;
217
231
  getVersion?(): any | Promise<any>;
218
232
  openInWord?(editId: string, html: string): Promise<{
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Inline-image extraction for outgoing mail.
3
+ *
4
+ * The compose editors embed pasted/inlined images as `data:` URIs — that's
5
+ * what renders inside a WebView editor with no server round-trip. But a
6
+ * `data:` URI inside the text/html MIME part is NOT interoperable mail:
7
+ * Outlook desktop (Word renderer) doesn't render data: images at all, and
8
+ * Gmail's web sanitizer strips them, so recipients on the two biggest
9
+ * platforms see broken/missing images while Thunderbird/Apple Mail users see
10
+ * the message fine. The interoperable form is RFC 2392 `cid:` references to
11
+ * base64 image parts inside a multipart/related wrapper — what every mail
12
+ * client has emitted since the 90s.
13
+ *
14
+ * This helper rewrites `<img src="data:...;base64,...">` to `cid:` refs and
15
+ * hands back the extracted parts; the platform send() paths (desktop
16
+ * mailx-service and Android/web web-service) wrap them into
17
+ * multipart/related. Shared here (mailx-types, zero deps) so both MIME
18
+ * assemblers can't drift apart.
19
+ *
20
+ * Non-base64 data: URIs (utf8 SVG etc.) are left in place — rare, small,
21
+ * and rewriting them would mean re-encoding; the size/interop problem this
22
+ * solves comes from pasted photos, which are always base64.
23
+ */
24
+ export interface InlineImagePart {
25
+ /** Content-ID value WITHOUT angle brackets (use `<${contentId}>` in the header). */
26
+ contentId: string;
27
+ mime: string;
28
+ base64: string;
29
+ }
30
+ /** Pull base64 `data:` images out of the HTML, replacing each src with a
31
+ * `cid:` reference. Identical data: URIs (the same image pasted twice)
32
+ * collapse to one part. `idDomain` scopes the Content-IDs (RFC 2392 wants
33
+ * msg-id-like uniqueness). */
34
+ export declare function extractInlineImages(html: string, idDomain: string): {
35
+ html: string;
36
+ images: InlineImagePart[];
37
+ };
38
+ //# sourceMappingURL=mime-inline-images.d.ts.map
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Inline-image extraction for outgoing mail.
3
+ *
4
+ * The compose editors embed pasted/inlined images as `data:` URIs — that's
5
+ * what renders inside a WebView editor with no server round-trip. But a
6
+ * `data:` URI inside the text/html MIME part is NOT interoperable mail:
7
+ * Outlook desktop (Word renderer) doesn't render data: images at all, and
8
+ * Gmail's web sanitizer strips them, so recipients on the two biggest
9
+ * platforms see broken/missing images while Thunderbird/Apple Mail users see
10
+ * the message fine. The interoperable form is RFC 2392 `cid:` references to
11
+ * base64 image parts inside a multipart/related wrapper — what every mail
12
+ * client has emitted since the 90s.
13
+ *
14
+ * This helper rewrites `<img src="data:...;base64,...">` to `cid:` refs and
15
+ * hands back the extracted parts; the platform send() paths (desktop
16
+ * mailx-service and Android/web web-service) wrap them into
17
+ * multipart/related. Shared here (mailx-types, zero deps) so both MIME
18
+ * assemblers can't drift apart.
19
+ *
20
+ * Non-base64 data: URIs (utf8 SVG etc.) are left in place — rare, small,
21
+ * and rewriting them would mean re-encoding; the size/interop problem this
22
+ * solves comes from pasted photos, which are always base64.
23
+ */
24
+ /** Pull base64 `data:` images out of the HTML, replacing each src with a
25
+ * `cid:` reference. Identical data: URIs (the same image pasted twice)
26
+ * collapse to one part. `idDomain` scopes the Content-IDs (RFC 2392 wants
27
+ * msg-id-like uniqueness). */
28
+ export function extractInlineImages(html, idDomain) {
29
+ const images = [];
30
+ const byDataUri = new Map(); // data-uri → contentId
31
+ const stamp = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
32
+ const out = html.replace(/(<img\b[^>]*?\ssrc=)(["'])(data:(image\/[\w.+-]+);base64,([A-Za-z0-9+/=\s]+))\2/gi, (_m, prefix, quote, dataUri, mime, b64) => {
33
+ let cid = byDataUri.get(dataUri);
34
+ if (!cid) {
35
+ cid = `img${images.length + 1}.${stamp}@${idDomain}`;
36
+ byDataUri.set(dataUri, cid);
37
+ // Strip whitespace the editor may have wrapped into the URI;
38
+ // the send path re-wraps at 76 cols per RFC 2045.
39
+ images.push({ contentId: cid, mime, base64: b64.replace(/\s+/g, "") });
40
+ }
41
+ return `${prefix}${quote}cid:${cid}${quote}`;
42
+ });
43
+ return { html: out, images };
44
+ }
45
+ //# sourceMappingURL=mime-inline-images.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-types",
3
- "version": "0.1.34",
3
+ "version": "0.1.38",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Reminder dismissed/snoozed state — shared across devices.
3
+ *
4
+ * The alarm subsystem (client/components/alarms.ts) keys every reminder by a
5
+ * per-occurrence string (`providerId:startMs[@offsetMs]` for calendar,
6
+ * `taskUuid:dueMs` for tasks). Those keys are built from Google-stable ids,
7
+ * so the SAME occurrence produces the SAME key on every device — which is
8
+ * what makes cross-device sync meaningful: dismiss a reminder on the desktop
9
+ * and the laptop's poller sees the dismissal before it fires its own popup.
10
+ *
11
+ * State lives in `reminders.jsonc` in the shared GDrive folder (same rails
12
+ * as allowlist.jsonc / userdict.csv). Values are epoch-ms timestamps:
13
+ * dismissed[key] = when the user dismissed it (retention: 30 days)
14
+ * snoozed[key] = fire again after this time (retention: 7 days past)
15
+ *
16
+ * Merge is per-key max — a dismissal or longer snooze on ANY device wins.
17
+ * Deletions don't propagate (union semantics); the only deletion in the
18
+ * alarm flow is the Open-button re-evaluate, which by construction touches
19
+ * a key that was never pushed as dismissed. Timestamps double as the prune
20
+ * criterion so the file can't grow forever.
21
+ *
22
+ * This module is dependency-free (mailx-types) so the desktop service
23
+ * (mailx-settings) and the Android/web service (mailx-store-web) share one
24
+ * merge implementation.
25
+ */
26
+ export interface ReminderState {
27
+ /** occurrence-key → epoch-ms when dismissed */
28
+ dismissed: Record<string, number>;
29
+ /** occurrence-key → epoch-ms snooze-until */
30
+ snoozed: Record<string, number>;
31
+ }
32
+ export declare const REMINDER_STATE_FILE = "reminders.jsonc";
33
+ /** Coerce arbitrary parsed JSON (or the client's localStorage maps, whose
34
+ * legacy dismissed values are boolean `true`) into a clean ReminderState.
35
+ * Legacy booleans become `now` so they get a full retention window. */
36
+ export declare function normalizeReminderState(raw: any, now: number): ReminderState;
37
+ /** Union merge with per-key max, pruned by retention. Inputs must already
38
+ * be normalized. Pure — does not mutate its arguments. */
39
+ export declare function mergeReminderStates(a: ReminderState, b: ReminderState, now: number): ReminderState;
40
+ /** Deep-equal for change detection so a no-op merge skips the cloud write. */
41
+ export declare function reminderStatesEqual(a: ReminderState, b: ReminderState): boolean;
42
+ //# sourceMappingURL=reminder-state.d.ts.map
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Reminder dismissed/snoozed state — shared across devices.
3
+ *
4
+ * The alarm subsystem (client/components/alarms.ts) keys every reminder by a
5
+ * per-occurrence string (`providerId:startMs[@offsetMs]` for calendar,
6
+ * `taskUuid:dueMs` for tasks). Those keys are built from Google-stable ids,
7
+ * so the SAME occurrence produces the SAME key on every device — which is
8
+ * what makes cross-device sync meaningful: dismiss a reminder on the desktop
9
+ * and the laptop's poller sees the dismissal before it fires its own popup.
10
+ *
11
+ * State lives in `reminders.jsonc` in the shared GDrive folder (same rails
12
+ * as allowlist.jsonc / userdict.csv). Values are epoch-ms timestamps:
13
+ * dismissed[key] = when the user dismissed it (retention: 30 days)
14
+ * snoozed[key] = fire again after this time (retention: 7 days past)
15
+ *
16
+ * Merge is per-key max — a dismissal or longer snooze on ANY device wins.
17
+ * Deletions don't propagate (union semantics); the only deletion in the
18
+ * alarm flow is the Open-button re-evaluate, which by construction touches
19
+ * a key that was never pushed as dismissed. Timestamps double as the prune
20
+ * criterion so the file can't grow forever.
21
+ *
22
+ * This module is dependency-free (mailx-types) so the desktop service
23
+ * (mailx-settings) and the Android/web service (mailx-store-web) share one
24
+ * merge implementation.
25
+ */
26
+ export const REMINDER_STATE_FILE = "reminders.jsonc";
27
+ /** Dismissed entries older than this are pruned — by then the occurrence is
28
+ * long past the alarm lookback window on every device. */
29
+ const DISMISSED_RETENTION_MS = 30 * 86400_000;
30
+ /** Snoozed entries whose until-time is this far past are pruned. */
31
+ const SNOOZED_RETENTION_MS = 7 * 86400_000;
32
+ /** Coerce arbitrary parsed JSON (or the client's localStorage maps, whose
33
+ * legacy dismissed values are boolean `true`) into a clean ReminderState.
34
+ * Legacy booleans become `now` so they get a full retention window. */
35
+ export function normalizeReminderState(raw, now) {
36
+ const out = { dismissed: {}, snoozed: {} };
37
+ if (raw && typeof raw === "object") {
38
+ for (const [k, v] of Object.entries(raw.dismissed || {})) {
39
+ if (v === true)
40
+ out.dismissed[k] = now;
41
+ else if (Number.isFinite(v) && v > 0)
42
+ out.dismissed[k] = v;
43
+ }
44
+ for (const [k, v] of Object.entries(raw.snoozed || {})) {
45
+ if (Number.isFinite(v) && v > 0)
46
+ out.snoozed[k] = v;
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+ /** Union merge with per-key max, pruned by retention. Inputs must already
52
+ * be normalized. Pure — does not mutate its arguments. */
53
+ export function mergeReminderStates(a, b, now) {
54
+ const out = { dismissed: {}, snoozed: {} };
55
+ const dismissedCutoff = now - DISMISSED_RETENTION_MS;
56
+ const snoozedCutoff = now - SNOOZED_RETENTION_MS;
57
+ for (const src of [a.dismissed, b.dismissed]) {
58
+ for (const [k, v] of Object.entries(src)) {
59
+ if (v < dismissedCutoff)
60
+ continue;
61
+ if (!(k in out.dismissed) || v > out.dismissed[k])
62
+ out.dismissed[k] = v;
63
+ }
64
+ }
65
+ for (const src of [a.snoozed, b.snoozed]) {
66
+ for (const [k, v] of Object.entries(src)) {
67
+ if (v < snoozedCutoff)
68
+ continue;
69
+ if (!(k in out.snoozed) || v > out.snoozed[k])
70
+ out.snoozed[k] = v;
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+ /** Deep-equal for change detection so a no-op merge skips the cloud write. */
76
+ export function reminderStatesEqual(a, b) {
77
+ const mapsEqual = (x, y) => {
78
+ const xk = Object.keys(x);
79
+ if (xk.length !== Object.keys(y).length)
80
+ return false;
81
+ return xk.every(k => y[k] === x[k]);
82
+ };
83
+ return mapsEqual(a.dismissed, b.dismissed) && mapsEqual(a.snoozed, b.snoozed);
84
+ }
85
+ //# sourceMappingURL=reminder-state.js.map