@camstack/addon-google-services 0.1.1 → 0.1.3

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.
@@ -18195,14 +18195,14 @@ var LlmProfileSchema = object({
18195
18195
  /** ConfigUISchema tree passed through untyped on the wire (the
18196
18196
  * notification-output `ConfigSchemaPassthrough` precedent at
18197
18197
  * notification-output.cap.ts:151); the exported TS type re-tightens it. */
18198
- var ConfigSchemaPassthrough$1 = unknown();
18198
+ var ConfigSchemaPassthrough$2 = unknown();
18199
18199
  var LlmProfileKindDescriptorSchema = object({
18200
18200
  kind: LlmProfileKindSchema,
18201
18201
  label: string(),
18202
18202
  icon: string(),
18203
18203
  /** Stamped by each provider so the concat-fanned catalog stays routable. */
18204
18204
  addonId: string(),
18205
- configSchema: ConfigSchemaPassthrough$1
18205
+ configSchema: ConfigSchemaPassthrough$2
18206
18206
  });
18207
18207
  var LlmDefaultSelectorSchema = union([object({ consumer: string() }), object({ purpose: _enum(["text", "vision"]) })]);
18208
18208
  var LlmDefaultSchema = object({
@@ -19323,7 +19323,7 @@ var TargetKindCapsSchema = object({
19323
19323
  * the union is large and not meant for runtime validation here; the exported
19324
19324
  * `TargetKind` type re-tightens `configSchema` to `ConfigUISchema`.
19325
19325
  */
19326
- var ConfigSchemaPassthrough = unknown();
19326
+ var ConfigSchemaPassthrough$1 = unknown();
19327
19327
  var TargetKindSchema = object({
19328
19328
  kind: string(),
19329
19329
  label: string(),
@@ -19360,7 +19360,7 @@ var TargetKindSchema = object({
19360
19360
  * its raster path, which is the safe default for an unknown image.
19361
19361
  */
19362
19362
  iconMediaType: string().optional(),
19363
- configSchema: ConfigSchemaPassthrough,
19363
+ configSchema: ConfigSchemaPassthrough$1,
19364
19364
  /**
19365
19365
  * The config keys that decide WHERE this kind's secrets are sent (server,
19366
19366
  * base URL, instance, chat, recipient). A save that changes any of them
@@ -28708,15 +28708,28 @@ var SmtpStatusSchema = object({
28708
28708
  /** Last successful verify timestamp (unix ms). */
28709
28709
  lastVerifiedAt: number().optional()
28710
28710
  });
28711
- method(SendEmailInputSchema, SendEmailResultSchema, {
28712
- kind: "mutation",
28713
- auth: "admin",
28714
- access: "create"
28715
- }), method(_void(), SmtpStatusSchema, {
28716
- kind: "mutation",
28717
- auth: "admin",
28718
- access: "view"
28719
- }), method(_void(), SmtpStatusSchema, { auth: "admin" });
28711
+ var smtpProviderCapability = {
28712
+ name: "smtp-provider",
28713
+ scope: "system",
28714
+ mode: "collection",
28715
+ internal: true,
28716
+ providerKind: "email",
28717
+ methods: {
28718
+ sendEmail: method(SendEmailInputSchema, SendEmailResultSchema, {
28719
+ kind: "mutation",
28720
+ auth: "admin",
28721
+ access: "create"
28722
+ }),
28723
+ /** Round-trip ping against the SMTP relay (EHLO + AUTH if configured).
28724
+ * Used by the operator's "Test connection" button. */
28725
+ verify: method(_void(), SmtpStatusSchema, {
28726
+ kind: "mutation",
28727
+ auth: "admin",
28728
+ access: "view"
28729
+ }),
28730
+ getStatus: method(_void(), SmtpStatusSchema, { auth: "admin" })
28731
+ }
28732
+ };
28720
28733
  /**
28721
28734
  * A single device snapshot returned as base64 JPEG/PNG.
28722
28735
  *
@@ -31683,6 +31696,7 @@ var calendarSourceCapability = {
31683
31696
  scope: "system",
31684
31697
  mode: "collection",
31685
31698
  internal: true,
31699
+ providerKind: "calendar",
31686
31700
  methods: {
31687
31701
  listCalendars: method(_void(), array(CalendarInfoSchema).readonly(), { auth: "admin" }),
31688
31702
  listEvents: method(CalendarListEventsInputSchema, array(CalendarEventSchema).readonly(), { auth: "admin" })
@@ -35164,6 +35178,7 @@ var OsdNamedTemplateSchema = object({
35164
35178
  });
35165
35179
  /** Where a slot's value comes from. */
35166
35180
  var OsdSourceSchema = discriminatedUnion("kind", [
35181
+ object({ kind: literal("none") }),
35167
35182
  object({
35168
35183
  kind: literal("static"),
35169
35184
  text: string().max(64)
@@ -39487,6 +39502,12 @@ var TrainingProviderDefaultsSchema = object({
39487
39502
  /** Hours a FAILED job's cloud dataset is kept for debugging; 0 = delete at once. */
39488
39503
  failedDatasetGraceHours: number().nonnegative()
39489
39504
  });
39505
+ /**
39506
+ * Entry form schema for a provider type — same seam as notification
39507
+ * `TargetKind.configSchema` (`z.unknown()` at the wire; callers tighten to
39508
+ * {@link ConfigUISchema}). Drives `ConfigFormBuilder` on Training → Providers.
39509
+ */
39510
+ var ConfigSchemaPassthrough = unknown();
39490
39511
  /** One creatable provider type, for the Add-entry dialog. */
39491
39512
  var TrainingProviderTypeSchema = object({
39492
39513
  typeId: string(),
@@ -39494,7 +39515,13 @@ var TrainingProviderTypeSchema = object({
39494
39515
  /** Path under the addon package (e.g. `assets/training-modal/icon.svg`). */
39495
39516
  iconAsset: string(),
39496
39517
  /** Owning `training-provider` addon — collection selector for createEntry. */
39497
- addonId: string()
39518
+ addonId: string(),
39519
+ /**
39520
+ * Fields for one entry of this type (credentials + defaults). Absent on a
39521
+ * type that only needs a name. Password fields are keychain-backed on the
39522
+ * provider addon (`secretStatePaths`), not D634 plaintext.
39523
+ */
39524
+ configSchema: ConfigSchemaPassthrough.optional()
39498
39525
  });
39499
39526
  /** One configured account / workspace under a provider type. */
39500
39527
  var TrainingProviderEntrySchema = object({
@@ -39524,7 +39551,30 @@ var TrainingProviderDescriptorSchema = object({
39524
39551
  dataResidencyNote: string(),
39525
39552
  /** Credentials are present — never the values. */
39526
39553
  configured: boolean(),
39527
- defaults: TrainingProviderDefaultsSchema
39554
+ defaults: TrainingProviderDefaultsSchema,
39555
+ /**
39556
+ * Values for {@link TrainingProviderTypeSchema}'s `configSchema` — password
39557
+ * fields are keychain {@link SecretFieldView} or `null` / `''` (create),
39558
+ * never plaintext. Same hydrate contract as settings forms (D789).
39559
+ */
39560
+ formValues: record(string(), unknown()).optional()
39561
+ });
39562
+ /**
39563
+ * Patch one entry's name, credentials and defaults. Pin with owning `{ addonId }`.
39564
+ * An omitted or empty `tokenSecret` means KEEP the stored secret (D787).
39565
+ * `{ $secretClear: true }` clears it.
39566
+ */
39567
+ var TrainingProviderUpdateEntryInputSchema = object({
39568
+ entryId: string().min(1),
39569
+ name: string().min(1),
39570
+ tokenId: string().optional(),
39571
+ tokenSecret: union([string(), object({ $secretClear: literal(true) })]).optional(),
39572
+ environment: string().optional(),
39573
+ gpuType: string().optional(),
39574
+ region: string().optional(),
39575
+ costCapUsd: number().nonnegative().optional(),
39576
+ maxRuntimeSec: number().int().positive().optional(),
39577
+ failedDatasetGraceHours: number().nonnegative().optional()
39528
39578
  });
39529
39579
  var TrainingCredentialCheckSchema = object({
39530
39580
  ok: boolean(),
@@ -39594,6 +39644,9 @@ method(object({}), array(TrainingProviderDescriptorSchema).readonly(), { auth: "
39594
39644
  }), method(object({ entryId: string().min(1) }), object({ ok: literal(true) }), {
39595
39645
  kind: "mutation",
39596
39646
  auth: "admin"
39647
+ }), method(TrainingProviderUpdateEntryInputSchema, object({ ok: literal(true) }), {
39648
+ kind: "mutation",
39649
+ auth: "admin"
39597
39650
  }), method(object({ entryId: string().min(1) }), TrainingCredentialCheckSchema, {
39598
39651
  kind: "mutation",
39599
39652
  auth: "admin",
@@ -47155,6 +47208,12 @@ Object.freeze({
47155
47208
  addonId: null,
47156
47209
  access: "create"
47157
47210
  },
47211
+ "trainingProvider.updateEntry": {
47212
+ capName: "training-provider",
47213
+ capScope: "system",
47214
+ addonId: null,
47215
+ access: "create"
47216
+ },
47158
47217
  "trainingProvider.uploadDataset": {
47159
47218
  capName: "training-provider",
47160
47219
  capScope: "system",
@@ -51700,6 +51759,7 @@ Object.freeze({
51700
51759
  });
51701
51760
  Object.freeze({
51702
51761
  "broker": "broker",
51762
+ "calendar-source": "calendar",
51703
51763
  "device-export": "device-export",
51704
51764
  "llm": "ai",
51705
51765
  "mesh-network": "mesh",
@@ -52744,7 +52804,12 @@ var GoogleServicesConfigSchema = object({
52744
52804
  clientId: string().default(""),
52745
52805
  clientSecret: string().default("").meta({ secret: true }),
52746
52806
  /** Comma-separated Google calendar ids to expose; empty = all. */
52747
- calendarFilter: string().default("")
52807
+ calendarFilter: string().default(""),
52808
+ /** Offer Gmail send via `smtp-provider` (same OAuth account). */
52809
+ gmailSendEnabled: boolean().default(false),
52810
+ /** Default From for Gmail send — usually the connected account email. */
52811
+ fromAddress: string().default(""),
52812
+ fromName: string().default("")
52748
52813
  });
52749
52814
  var DEFAULT_GOOGLE_CONFIG = GoogleServicesConfigSchema.parse({});
52750
52815
  var DEFAULT_OAUTH_STATE = {
@@ -52754,9 +52819,14 @@ var DEFAULT_OAUTH_STATE = {
52754
52819
  accountEmail: ""
52755
52820
  };
52756
52821
  var GOOGLE_CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar.readonly";
52822
+ var GOOGLE_GMAIL_SEND_SCOPE = "https://www.googleapis.com/auth/gmail.send";
52823
+ /** Space-separated scopes requested on Connect (Calendar read + Gmail send). */
52824
+ var GOOGLE_OAUTH_SCOPES = `${GOOGLE_CALENDAR_SCOPE} ${GOOGLE_GMAIL_SEND_SCOPE}`;
52757
52825
  var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
52758
52826
  var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
52759
52827
  var GOOGLE_CALENDAR_API = "https://www.googleapis.com/calendar/v3";
52828
+ var GOOGLE_GMAIL_API = "https://gmail.googleapis.com/gmail/v1";
52829
+ var GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo";
52760
52830
  //#endregion
52761
52831
  //#region src/google-calendar-source.ts
52762
52832
  function parseGoogleInstant(value) {
@@ -52859,6 +52929,148 @@ function createGoogleCalendarSource(deps) {
52859
52929
  };
52860
52930
  }
52861
52931
  //#endregion
52932
+ //#region src/google-gmail-smtp.ts
52933
+ function encodeBase64Url(bytes) {
52934
+ return Buffer.from(bytes).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
52935
+ }
52936
+ function encodeHeaderWord(value) {
52937
+ if (/^[\x20-\x7E]*$/.test(value)) return value;
52938
+ return `=?UTF-8?B?${Buffer.from(value, "utf8").toString("base64")}?=`;
52939
+ }
52940
+ function formatAddressList(addrs) {
52941
+ return (Array.isArray(addrs) ? addrs : [addrs]).join(", ");
52942
+ }
52943
+ function buildFromHeader(cfg, accountEmail) {
52944
+ const addr = cfg.fromAddress.trim() || accountEmail;
52945
+ if (!addr) return "";
52946
+ if (cfg.fromName.trim()) return `${encodeHeaderWord(cfg.fromName.trim())} <${addr}>`;
52947
+ return addr;
52948
+ }
52949
+ /** Build a minimal multipart/alternative-capable plain (and optional HTML) message. */
52950
+ function buildRawMime(input) {
52951
+ const lines = [`From: ${input.from}`, `To: ${formatAddressList(input.to)}`];
52952
+ if (input.cc !== void 0 && input.cc.length > 0) lines.push(`Cc: ${formatAddressList(input.cc)}`);
52953
+ if (input.bcc !== void 0 && input.bcc.length > 0) lines.push(`Bcc: ${formatAddressList(input.bcc)}`);
52954
+ if (input.replyTo !== void 0 && input.replyTo.length > 0) lines.push(`Reply-To: ${input.replyTo}`);
52955
+ lines.push(`Subject: ${encodeHeaderWord(input.subject)}`);
52956
+ lines.push("MIME-Version: 1.0");
52957
+ if (input.html !== void 0 && input.html.length > 0) {
52958
+ const boundary = `camstack_${Date.now().toString(36)}`;
52959
+ lines.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
52960
+ lines.push("");
52961
+ lines.push(`--${boundary}`);
52962
+ lines.push("Content-Type: text/plain; charset=\"UTF-8\"");
52963
+ lines.push("Content-Transfer-Encoding: 8bit");
52964
+ lines.push("");
52965
+ lines.push(input.text);
52966
+ lines.push(`--${boundary}`);
52967
+ lines.push("Content-Type: text/html; charset=\"UTF-8\"");
52968
+ lines.push("Content-Transfer-Encoding: 8bit");
52969
+ lines.push("");
52970
+ lines.push(input.html);
52971
+ lines.push(`--${boundary}--`);
52972
+ } else {
52973
+ lines.push("Content-Type: text/plain; charset=\"UTF-8\"");
52974
+ lines.push("Content-Transfer-Encoding: 8bit");
52975
+ lines.push("");
52976
+ lines.push(input.text);
52977
+ }
52978
+ return lines.join("\r\n");
52979
+ }
52980
+ function createGmailSmtpProvider(deps) {
52981
+ const http = deps.fetch ?? fetch;
52982
+ let lastVerifiedAt;
52983
+ let lastError;
52984
+ const status = () => {
52985
+ const cfg = deps.getConfig();
52986
+ const email = deps.getAccountEmail() || cfg.fromAddress.trim() || "gmail";
52987
+ if (!cfg.gmailSendEnabled) return {
52988
+ ready: false,
52989
+ host: email,
52990
+ error: "Gmail send is disabled in Google Services settings.",
52991
+ lastVerifiedAt
52992
+ };
52993
+ if (!deps.connected()) return {
52994
+ ready: false,
52995
+ host: email,
52996
+ error: "Google account is not connected — open Connect Google.",
52997
+ lastVerifiedAt
52998
+ };
52999
+ return {
53000
+ ready: lastError === void 0 && lastVerifiedAt !== void 0,
53001
+ host: email,
53002
+ error: lastError,
53003
+ lastVerifiedAt
53004
+ };
53005
+ };
53006
+ return {
53007
+ getStatus: async () => status(),
53008
+ verify: async () => {
53009
+ if (!deps.getConfig().gmailSendEnabled) {
53010
+ lastError = "Gmail send is disabled in Google Services settings.";
53011
+ return status();
53012
+ }
53013
+ if (!deps.connected()) {
53014
+ lastError = "Google account is not connected.";
53015
+ return status();
53016
+ }
53017
+ try {
53018
+ const token = await deps.getAccessToken();
53019
+ const res = await http(`${GOOGLE_GMAIL_API}/users/me/profile`, { headers: { Authorization: `Bearer ${token}` } });
53020
+ if (!res.ok) {
53021
+ const text = await res.text();
53022
+ lastError = `Gmail profile failed (${res.status}): ${text.slice(0, 200)}`;
53023
+ return status();
53024
+ }
53025
+ lastError = void 0;
53026
+ lastVerifiedAt = Date.now();
53027
+ return status();
53028
+ } catch (err) {
53029
+ lastError = err instanceof Error ? err.message : String(err);
53030
+ return status();
53031
+ }
53032
+ },
53033
+ sendEmail: async (input) => {
53034
+ const cfg = deps.getConfig();
53035
+ if (!cfg.gmailSendEnabled) throw new Error("Gmail send is disabled in Google Services settings.");
53036
+ if (!deps.connected()) throw new Error("Google account is not connected — open Connect Google.");
53037
+ const from = input.from?.trim() || buildFromHeader(cfg, deps.getAccountEmail());
53038
+ if (!from) throw new Error("Set From address in Google Services settings (or connect an account).");
53039
+ const raw = buildRawMime({
53040
+ from,
53041
+ to: input.to,
53042
+ cc: input.cc,
53043
+ bcc: input.bcc,
53044
+ replyTo: input.replyTo,
53045
+ subject: input.subject,
53046
+ text: input.text,
53047
+ html: input.html
53048
+ });
53049
+ const token = await deps.getAccessToken();
53050
+ const res = await http(`${GOOGLE_GMAIL_API}/users/me/messages/send`, {
53051
+ method: "POST",
53052
+ headers: {
53053
+ Authorization: `Bearer ${token}`,
53054
+ "Content-Type": "application/json"
53055
+ },
53056
+ body: JSON.stringify({ raw: encodeBase64Url(Buffer.from(raw, "utf8")) })
53057
+ });
53058
+ if (!res.ok) {
53059
+ const text = await res.text();
53060
+ throw new Error(`Gmail send failed (${res.status}): ${text.slice(0, 400)}`);
53061
+ }
53062
+ const messageId = (await res.json()).id ?? `gmail-${Date.now()}`;
53063
+ lastError = void 0;
53064
+ lastVerifiedAt = Date.now();
53065
+ return {
53066
+ messageId,
53067
+ accepted: [...Array.isArray(input.to) ? input.to : [input.to]],
53068
+ rejected: []
53069
+ };
53070
+ }
53071
+ };
53072
+ }
53073
+ //#endregion
52862
53074
  //#region src/google-oauth.ts
52863
53075
  /**
52864
53076
  * Google OAuth2 authorization-code + refresh for Calendar API.
@@ -52868,7 +53080,7 @@ function buildAuthorizeUrl(cfg, state) {
52868
53080
  u.searchParams.set("client_id", cfg.clientId);
52869
53081
  u.searchParams.set("redirect_uri", cfg.redirectUri);
52870
53082
  u.searchParams.set("response_type", "code");
52871
- u.searchParams.set("scope", GOOGLE_CALENDAR_SCOPE);
53083
+ u.searchParams.set("scope", GOOGLE_OAUTH_SCOPES);
52872
53084
  u.searchParams.set("access_type", "offline");
52873
53085
  u.searchParams.set("prompt", "consent");
52874
53086
  u.searchParams.set("state", state);
@@ -52926,9 +53138,19 @@ function needsRefresh(state, nowMs = Date.now()) {
52926
53138
  return state.accessExpiresAt <= nowMs;
52927
53139
  }
52928
53140
  //#endregion
53141
+ //#region src/oauth-redirect-origin.ts
53142
+ /**
53143
+ * Google OAuth redirect origin — same role as hub OAuth2 / sign-in
53144
+ * (`local-network` cap: `roles.publicEndpoint`), never `internal` (LAN / agents).
53145
+ * Google rejects non-localhost `http://` redirect URIs for Web clients.
53146
+ */
53147
+ function pickOauthRedirectOrigin(book) {
53148
+ return book.roles.publicEndpoint.resolved ?? book.roles.internal.resolved ?? null;
53149
+ }
53150
+ //#endregion
52929
53151
  //#region src/google-services.addon.ts
52930
53152
  /**
52931
- * Google Services — hub-only calendar-source over Google Calendar API (OAuth2).
53153
+ * Google Services — hub-only calendar-source + Gmail smtp-provider (same OAuth2).
52932
53154
  */
52933
53155
  var ADDON_ID = "google-services";
52934
53156
  var CALLBACK_PATH = "/oauth/callback";
@@ -52952,17 +53174,23 @@ var GoogleServicesAddon = class extends BaseAddon {
52952
53174
  this.oauthHandle = this.state("oauth", OauthStateSchema, { ...DEFAULT_OAUTH_STATE });
52953
53175
  this.oauth = await this.oauthHandle.get();
52954
53176
  const api = createGoogleCalendarApi(async () => this.ensureAccessToken());
52955
- const provider = createGoogleCalendarSource({
53177
+ const calendarProvider = createGoogleCalendarSource({
52956
53178
  logger: this.ctx.logger,
52957
53179
  api,
52958
53180
  connected: () => this.oauth.refreshToken.length > 0,
52959
53181
  filterIds: () => this.parseFilter()
52960
53182
  });
53183
+ const smtpProvider = createGmailSmtpProvider({
53184
+ getAccessToken: () => this.ensureAccessToken(),
53185
+ getConfig: () => this.config,
53186
+ getAccountEmail: () => this.oauth.accountEmail,
53187
+ connected: () => this.oauth.refreshToken.length > 0
53188
+ });
52961
53189
  const routeProvider = buildAddonRouteProvider(ADDON_ID, [{
52962
53190
  method: "GET",
52963
53191
  path: START_PATH,
52964
53192
  access: "admin",
52965
- description: "Begin Google Calendar OAuth",
53193
+ description: "Begin Google OAuth (Calendar + Gmail send)",
52966
53194
  handler: async (_req, reply) => this.handleStart(reply)
52967
53195
  }, {
52968
53196
  method: "GET",
@@ -52972,17 +53200,25 @@ var GoogleServicesAddon = class extends BaseAddon {
52972
53200
  description: "Google OAuth callback",
52973
53201
  handler: async (req, reply) => this.handleCallback(req, reply)
52974
53202
  }]);
52975
- this.ctx.logger.info("Initialized — google calendar-source", { meta: {
53203
+ this.ctx.logger.info("Initialized — google calendar-source + gmail smtp", { meta: {
52976
53204
  connected: this.oauth.refreshToken.length > 0,
52977
- accountEmail: this.oauth.accountEmail || void 0
53205
+ accountEmail: this.oauth.accountEmail || void 0,
53206
+ gmailSendEnabled: this.config.gmailSendEnabled
52978
53207
  } });
52979
- return { providers: [{
52980
- capability: calendarSourceCapability,
52981
- provider
52982
- }, {
52983
- capability: addonRoutesCapability,
52984
- provider: routeProvider
52985
- }] };
53208
+ return { providers: [
53209
+ {
53210
+ capability: calendarSourceCapability,
53211
+ provider: calendarProvider
53212
+ },
53213
+ {
53214
+ capability: smtpProviderCapability,
53215
+ provider: smtpProvider
53216
+ },
53217
+ {
53218
+ capability: addonRoutesCapability,
53219
+ provider: routeProvider
53220
+ }
53221
+ ] };
52986
53222
  }
52987
53223
  async handleStart(reply) {
52988
53224
  if (!this.config.clientId || !this.config.clientSecret) {
@@ -53028,15 +53264,33 @@ var GoogleServicesAddon = class extends BaseAddon {
53028
53264
  }, code);
53029
53265
  if (!tokens.refresh_token && !this.oauth.refreshToken) throw new Error("Google did not return a refresh_token. Revoke CamStack access in Google Account → Security and retry with prompt=consent.");
53030
53266
  this.oauth = applyTokenResponse(this.oauth, tokens);
53267
+ const email = await this.fetchAccountEmail(this.oauth.accessToken);
53268
+ if (email) this.oauth = {
53269
+ ...this.oauth,
53270
+ accountEmail: email
53271
+ };
53031
53272
  await this.persistOauth();
53032
- this.ctx.logger.info("google oauth connected", { meta: { hasRefresh: this.oauth.refreshToken.length > 0 } });
53033
- this.html(reply, 200, "Google Calendar connected. You can close this window.");
53273
+ this.ctx.logger.info("google oauth connected", { meta: {
53274
+ hasRefresh: this.oauth.refreshToken.length > 0,
53275
+ accountEmail: this.oauth.accountEmail || void 0
53276
+ } });
53277
+ this.html(reply, 200, "Google account connected (Calendar + Gmail send). You can close this window.");
53034
53278
  } catch (e) {
53035
53279
  const message = e instanceof Error ? e.message : String(e);
53036
53280
  this.ctx.logger.warn("google oauth callback failed", { meta: { error: message } });
53037
53281
  this.html(reply, 500, `OAuth failed: ${message}`);
53038
53282
  }
53039
53283
  }
53284
+ async fetchAccountEmail(accessToken) {
53285
+ try {
53286
+ const res = await fetch(GOOGLE_USERINFO_URL, { headers: { Authorization: `Bearer ${accessToken}` } });
53287
+ if (!res.ok) return "";
53288
+ const body = await res.json();
53289
+ return typeof body.email === "string" ? body.email.trim() : "";
53290
+ } catch {
53291
+ return "";
53292
+ }
53293
+ }
53040
53294
  html(reply, code, body) {
53041
53295
  reply.header("content-type", "text/html; charset=utf-8");
53042
53296
  reply.status(code).send(`<!doctype html><html><body><p>${escapeHtml(body)}</p></body></html>`);
@@ -53066,16 +53320,15 @@ var GoogleServicesAddon = class extends BaseAddon {
53066
53320
  return new Set(raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0));
53067
53321
  }
53068
53322
  async resolveRedirectUri() {
53069
- const book = await this.ctx.api.localNetwork.getAddresses.query({});
53070
- const origin = book.roles.internal.resolved ?? book.roles.publicEndpoint.resolved ?? null;
53071
- if (!origin) throw new Error("No hub address available from local-network — set an Internal address on the Network page.");
53323
+ const origin = pickOauthRedirectOrigin(await this.ctx.api.localNetwork.getAddresses.query({}));
53324
+ if (!origin) throw new Error("No hub public address available from local-network — set a Public endpoint on the Network page (OAuth2 redirects use that role, not Internal).");
53072
53325
  return `${origin.replace(/\/$/, "")}/addon/${ADDON_ID}${CALLBACK_PATH}`;
53073
53326
  }
53074
53327
  globalSettingsSchema() {
53075
53328
  return this.schema({ sections: [{
53076
53329
  id: "google-oauth",
53077
- title: "Google Calendar OAuth",
53078
- description: "Create an OAuth client (Web application) in Google Cloud Console with the Calendar API enabled. Register the redirect URI `<hub>/addon/google-services/oauth/callback` (hub = Internal address from Network).",
53330
+ title: "Google OAuth",
53331
+ description: "Create an OAuth client (Web application) in Google Cloud Console with the Calendar API and Gmail API enabled. Connect grants both Calendar read and Gmail send on the same account. Register the redirect URI `<public-hub>/addon/google-services/oauth/callback` — hub = Public endpoint from Network (same role as sign-in / OAuth2; Google rejects plain LAN http).",
53079
53332
  columns: 1,
53080
53333
  fields: [
53081
53334
  {
@@ -53100,7 +53353,31 @@ var GoogleServicesAddon = class extends BaseAddon {
53100
53353
  key: "_connectInfo",
53101
53354
  label: "Connect",
53102
53355
  format: "html",
53103
- content: `After saving client id/secret, open <a href="/addon/${ADDON_ID}${START_PATH}">Connect Google Calendar</a> while signed in as admin.`
53356
+ content: `After saving client id/secret, open <a href="/addon/${ADDON_ID}${START_PATH}">Connect Google</a> while signed in as admin. Redirect URI uses the Network <b>Public endpoint</b> (today typically <code>https://…/addon/google-services/oauth/callback</code>). If you connected before Gmail send was added, revoke CamStack in Google Account security and reconnect so the refresh token includes <code>gmail.send</code>.`
53357
+ }
53358
+ ]
53359
+ }, {
53360
+ id: "gmail-send",
53361
+ title: "Gmail send",
53362
+ description: "Optional email delivery via the Gmail API (`smtp-provider`). Uses the same OAuth account — From must be that account or a verified send-as alias.",
53363
+ columns: 1,
53364
+ fields: [
53365
+ {
53366
+ type: "boolean",
53367
+ key: "gmailSendEnabled",
53368
+ label: "Enable Gmail send",
53369
+ description: "When off, Calendar still works; this addon reports smtp-provider not ready."
53370
+ },
53371
+ {
53372
+ type: "text",
53373
+ key: "fromAddress",
53374
+ label: "From address",
53375
+ description: "Defaults to the connected Google account email when empty."
53376
+ },
53377
+ {
53378
+ type: "text",
53379
+ key: "fromName",
53380
+ label: "From display name (optional)"
53104
53381
  }
53105
53382
  ]
53106
53383
  }] });
@@ -53111,11 +53388,15 @@ function escapeHtml(s) {
53111
53388
  }
53112
53389
  //#endregion
53113
53390
  exports.DEFAULT_GOOGLE_CONFIG = DEFAULT_GOOGLE_CONFIG;
53391
+ exports.GOOGLE_GMAIL_SEND_SCOPE = GOOGLE_GMAIL_SEND_SCOPE;
53392
+ exports.GOOGLE_OAUTH_SCOPES = GOOGLE_OAUTH_SCOPES;
53114
53393
  exports.GoogleServicesAddon = GoogleServicesAddon;
53115
53394
  exports.default = GoogleServicesAddon;
53116
53395
  exports.GoogleServicesConfigSchema = GoogleServicesConfigSchema;
53117
53396
  exports.applyTokenResponse = applyTokenResponse;
53118
53397
  exports.buildAuthorizeUrl = buildAuthorizeUrl;
53398
+ exports.buildRawMime = buildRawMime;
53399
+ exports.createGmailSmtpProvider = createGmailSmtpProvider;
53119
53400
  exports.createGoogleCalendarApi = createGoogleCalendarApi;
53120
53401
  exports.createGoogleCalendarSource = createGoogleCalendarSource;
53121
53402
  exports.exchangeCode = exchangeCode;
@@ -18191,14 +18191,14 @@ var LlmProfileSchema = object({
18191
18191
  /** ConfigUISchema tree passed through untyped on the wire (the
18192
18192
  * notification-output `ConfigSchemaPassthrough` precedent at
18193
18193
  * notification-output.cap.ts:151); the exported TS type re-tightens it. */
18194
- var ConfigSchemaPassthrough$1 = unknown();
18194
+ var ConfigSchemaPassthrough$2 = unknown();
18195
18195
  var LlmProfileKindDescriptorSchema = object({
18196
18196
  kind: LlmProfileKindSchema,
18197
18197
  label: string(),
18198
18198
  icon: string(),
18199
18199
  /** Stamped by each provider so the concat-fanned catalog stays routable. */
18200
18200
  addonId: string(),
18201
- configSchema: ConfigSchemaPassthrough$1
18201
+ configSchema: ConfigSchemaPassthrough$2
18202
18202
  });
18203
18203
  var LlmDefaultSelectorSchema = union([object({ consumer: string() }), object({ purpose: _enum(["text", "vision"]) })]);
18204
18204
  var LlmDefaultSchema = object({
@@ -19319,7 +19319,7 @@ var TargetKindCapsSchema = object({
19319
19319
  * the union is large and not meant for runtime validation here; the exported
19320
19320
  * `TargetKind` type re-tightens `configSchema` to `ConfigUISchema`.
19321
19321
  */
19322
- var ConfigSchemaPassthrough = unknown();
19322
+ var ConfigSchemaPassthrough$1 = unknown();
19323
19323
  var TargetKindSchema = object({
19324
19324
  kind: string(),
19325
19325
  label: string(),
@@ -19356,7 +19356,7 @@ var TargetKindSchema = object({
19356
19356
  * its raster path, which is the safe default for an unknown image.
19357
19357
  */
19358
19358
  iconMediaType: string().optional(),
19359
- configSchema: ConfigSchemaPassthrough,
19359
+ configSchema: ConfigSchemaPassthrough$1,
19360
19360
  /**
19361
19361
  * The config keys that decide WHERE this kind's secrets are sent (server,
19362
19362
  * base URL, instance, chat, recipient). A save that changes any of them
@@ -28704,15 +28704,28 @@ var SmtpStatusSchema = object({
28704
28704
  /** Last successful verify timestamp (unix ms). */
28705
28705
  lastVerifiedAt: number().optional()
28706
28706
  });
28707
- method(SendEmailInputSchema, SendEmailResultSchema, {
28708
- kind: "mutation",
28709
- auth: "admin",
28710
- access: "create"
28711
- }), method(_void(), SmtpStatusSchema, {
28712
- kind: "mutation",
28713
- auth: "admin",
28714
- access: "view"
28715
- }), method(_void(), SmtpStatusSchema, { auth: "admin" });
28707
+ var smtpProviderCapability = {
28708
+ name: "smtp-provider",
28709
+ scope: "system",
28710
+ mode: "collection",
28711
+ internal: true,
28712
+ providerKind: "email",
28713
+ methods: {
28714
+ sendEmail: method(SendEmailInputSchema, SendEmailResultSchema, {
28715
+ kind: "mutation",
28716
+ auth: "admin",
28717
+ access: "create"
28718
+ }),
28719
+ /** Round-trip ping against the SMTP relay (EHLO + AUTH if configured).
28720
+ * Used by the operator's "Test connection" button. */
28721
+ verify: method(_void(), SmtpStatusSchema, {
28722
+ kind: "mutation",
28723
+ auth: "admin",
28724
+ access: "view"
28725
+ }),
28726
+ getStatus: method(_void(), SmtpStatusSchema, { auth: "admin" })
28727
+ }
28728
+ };
28716
28729
  /**
28717
28730
  * A single device snapshot returned as base64 JPEG/PNG.
28718
28731
  *
@@ -31679,6 +31692,7 @@ var calendarSourceCapability = {
31679
31692
  scope: "system",
31680
31693
  mode: "collection",
31681
31694
  internal: true,
31695
+ providerKind: "calendar",
31682
31696
  methods: {
31683
31697
  listCalendars: method(_void(), array(CalendarInfoSchema).readonly(), { auth: "admin" }),
31684
31698
  listEvents: method(CalendarListEventsInputSchema, array(CalendarEventSchema).readonly(), { auth: "admin" })
@@ -35160,6 +35174,7 @@ var OsdNamedTemplateSchema = object({
35160
35174
  });
35161
35175
  /** Where a slot's value comes from. */
35162
35176
  var OsdSourceSchema = discriminatedUnion("kind", [
35177
+ object({ kind: literal("none") }),
35163
35178
  object({
35164
35179
  kind: literal("static"),
35165
35180
  text: string().max(64)
@@ -39483,6 +39498,12 @@ var TrainingProviderDefaultsSchema = object({
39483
39498
  /** Hours a FAILED job's cloud dataset is kept for debugging; 0 = delete at once. */
39484
39499
  failedDatasetGraceHours: number().nonnegative()
39485
39500
  });
39501
+ /**
39502
+ * Entry form schema for a provider type — same seam as notification
39503
+ * `TargetKind.configSchema` (`z.unknown()` at the wire; callers tighten to
39504
+ * {@link ConfigUISchema}). Drives `ConfigFormBuilder` on Training → Providers.
39505
+ */
39506
+ var ConfigSchemaPassthrough = unknown();
39486
39507
  /** One creatable provider type, for the Add-entry dialog. */
39487
39508
  var TrainingProviderTypeSchema = object({
39488
39509
  typeId: string(),
@@ -39490,7 +39511,13 @@ var TrainingProviderTypeSchema = object({
39490
39511
  /** Path under the addon package (e.g. `assets/training-modal/icon.svg`). */
39491
39512
  iconAsset: string(),
39492
39513
  /** Owning `training-provider` addon — collection selector for createEntry. */
39493
- addonId: string()
39514
+ addonId: string(),
39515
+ /**
39516
+ * Fields for one entry of this type (credentials + defaults). Absent on a
39517
+ * type that only needs a name. Password fields are keychain-backed on the
39518
+ * provider addon (`secretStatePaths`), not D634 plaintext.
39519
+ */
39520
+ configSchema: ConfigSchemaPassthrough.optional()
39494
39521
  });
39495
39522
  /** One configured account / workspace under a provider type. */
39496
39523
  var TrainingProviderEntrySchema = object({
@@ -39520,7 +39547,30 @@ var TrainingProviderDescriptorSchema = object({
39520
39547
  dataResidencyNote: string(),
39521
39548
  /** Credentials are present — never the values. */
39522
39549
  configured: boolean(),
39523
- defaults: TrainingProviderDefaultsSchema
39550
+ defaults: TrainingProviderDefaultsSchema,
39551
+ /**
39552
+ * Values for {@link TrainingProviderTypeSchema}'s `configSchema` — password
39553
+ * fields are keychain {@link SecretFieldView} or `null` / `''` (create),
39554
+ * never plaintext. Same hydrate contract as settings forms (D789).
39555
+ */
39556
+ formValues: record(string(), unknown()).optional()
39557
+ });
39558
+ /**
39559
+ * Patch one entry's name, credentials and defaults. Pin with owning `{ addonId }`.
39560
+ * An omitted or empty `tokenSecret` means KEEP the stored secret (D787).
39561
+ * `{ $secretClear: true }` clears it.
39562
+ */
39563
+ var TrainingProviderUpdateEntryInputSchema = object({
39564
+ entryId: string().min(1),
39565
+ name: string().min(1),
39566
+ tokenId: string().optional(),
39567
+ tokenSecret: union([string(), object({ $secretClear: literal(true) })]).optional(),
39568
+ environment: string().optional(),
39569
+ gpuType: string().optional(),
39570
+ region: string().optional(),
39571
+ costCapUsd: number().nonnegative().optional(),
39572
+ maxRuntimeSec: number().int().positive().optional(),
39573
+ failedDatasetGraceHours: number().nonnegative().optional()
39524
39574
  });
39525
39575
  var TrainingCredentialCheckSchema = object({
39526
39576
  ok: boolean(),
@@ -39590,6 +39640,9 @@ method(object({}), array(TrainingProviderDescriptorSchema).readonly(), { auth: "
39590
39640
  }), method(object({ entryId: string().min(1) }), object({ ok: literal(true) }), {
39591
39641
  kind: "mutation",
39592
39642
  auth: "admin"
39643
+ }), method(TrainingProviderUpdateEntryInputSchema, object({ ok: literal(true) }), {
39644
+ kind: "mutation",
39645
+ auth: "admin"
39593
39646
  }), method(object({ entryId: string().min(1) }), TrainingCredentialCheckSchema, {
39594
39647
  kind: "mutation",
39595
39648
  auth: "admin",
@@ -47151,6 +47204,12 @@ Object.freeze({
47151
47204
  addonId: null,
47152
47205
  access: "create"
47153
47206
  },
47207
+ "trainingProvider.updateEntry": {
47208
+ capName: "training-provider",
47209
+ capScope: "system",
47210
+ addonId: null,
47211
+ access: "create"
47212
+ },
47154
47213
  "trainingProvider.uploadDataset": {
47155
47214
  capName: "training-provider",
47156
47215
  capScope: "system",
@@ -51696,6 +51755,7 @@ Object.freeze({
51696
51755
  });
51697
51756
  Object.freeze({
51698
51757
  "broker": "broker",
51758
+ "calendar-source": "calendar",
51699
51759
  "device-export": "device-export",
51700
51760
  "llm": "ai",
51701
51761
  "mesh-network": "mesh",
@@ -52740,7 +52800,12 @@ var GoogleServicesConfigSchema = object({
52740
52800
  clientId: string().default(""),
52741
52801
  clientSecret: string().default("").meta({ secret: true }),
52742
52802
  /** Comma-separated Google calendar ids to expose; empty = all. */
52743
- calendarFilter: string().default("")
52803
+ calendarFilter: string().default(""),
52804
+ /** Offer Gmail send via `smtp-provider` (same OAuth account). */
52805
+ gmailSendEnabled: boolean().default(false),
52806
+ /** Default From for Gmail send — usually the connected account email. */
52807
+ fromAddress: string().default(""),
52808
+ fromName: string().default("")
52744
52809
  });
52745
52810
  var DEFAULT_GOOGLE_CONFIG = GoogleServicesConfigSchema.parse({});
52746
52811
  var DEFAULT_OAUTH_STATE = {
@@ -52750,9 +52815,14 @@ var DEFAULT_OAUTH_STATE = {
52750
52815
  accountEmail: ""
52751
52816
  };
52752
52817
  var GOOGLE_CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar.readonly";
52818
+ var GOOGLE_GMAIL_SEND_SCOPE = "https://www.googleapis.com/auth/gmail.send";
52819
+ /** Space-separated scopes requested on Connect (Calendar read + Gmail send). */
52820
+ var GOOGLE_OAUTH_SCOPES = `${GOOGLE_CALENDAR_SCOPE} ${GOOGLE_GMAIL_SEND_SCOPE}`;
52753
52821
  var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
52754
52822
  var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
52755
52823
  var GOOGLE_CALENDAR_API = "https://www.googleapis.com/calendar/v3";
52824
+ var GOOGLE_GMAIL_API = "https://gmail.googleapis.com/gmail/v1";
52825
+ var GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo";
52756
52826
  //#endregion
52757
52827
  //#region src/google-calendar-source.ts
52758
52828
  function parseGoogleInstant(value) {
@@ -52855,6 +52925,148 @@ function createGoogleCalendarSource(deps) {
52855
52925
  };
52856
52926
  }
52857
52927
  //#endregion
52928
+ //#region src/google-gmail-smtp.ts
52929
+ function encodeBase64Url(bytes) {
52930
+ return Buffer.from(bytes).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
52931
+ }
52932
+ function encodeHeaderWord(value) {
52933
+ if (/^[\x20-\x7E]*$/.test(value)) return value;
52934
+ return `=?UTF-8?B?${Buffer.from(value, "utf8").toString("base64")}?=`;
52935
+ }
52936
+ function formatAddressList(addrs) {
52937
+ return (Array.isArray(addrs) ? addrs : [addrs]).join(", ");
52938
+ }
52939
+ function buildFromHeader(cfg, accountEmail) {
52940
+ const addr = cfg.fromAddress.trim() || accountEmail;
52941
+ if (!addr) return "";
52942
+ if (cfg.fromName.trim()) return `${encodeHeaderWord(cfg.fromName.trim())} <${addr}>`;
52943
+ return addr;
52944
+ }
52945
+ /** Build a minimal multipart/alternative-capable plain (and optional HTML) message. */
52946
+ function buildRawMime(input) {
52947
+ const lines = [`From: ${input.from}`, `To: ${formatAddressList(input.to)}`];
52948
+ if (input.cc !== void 0 && input.cc.length > 0) lines.push(`Cc: ${formatAddressList(input.cc)}`);
52949
+ if (input.bcc !== void 0 && input.bcc.length > 0) lines.push(`Bcc: ${formatAddressList(input.bcc)}`);
52950
+ if (input.replyTo !== void 0 && input.replyTo.length > 0) lines.push(`Reply-To: ${input.replyTo}`);
52951
+ lines.push(`Subject: ${encodeHeaderWord(input.subject)}`);
52952
+ lines.push("MIME-Version: 1.0");
52953
+ if (input.html !== void 0 && input.html.length > 0) {
52954
+ const boundary = `camstack_${Date.now().toString(36)}`;
52955
+ lines.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
52956
+ lines.push("");
52957
+ lines.push(`--${boundary}`);
52958
+ lines.push("Content-Type: text/plain; charset=\"UTF-8\"");
52959
+ lines.push("Content-Transfer-Encoding: 8bit");
52960
+ lines.push("");
52961
+ lines.push(input.text);
52962
+ lines.push(`--${boundary}`);
52963
+ lines.push("Content-Type: text/html; charset=\"UTF-8\"");
52964
+ lines.push("Content-Transfer-Encoding: 8bit");
52965
+ lines.push("");
52966
+ lines.push(input.html);
52967
+ lines.push(`--${boundary}--`);
52968
+ } else {
52969
+ lines.push("Content-Type: text/plain; charset=\"UTF-8\"");
52970
+ lines.push("Content-Transfer-Encoding: 8bit");
52971
+ lines.push("");
52972
+ lines.push(input.text);
52973
+ }
52974
+ return lines.join("\r\n");
52975
+ }
52976
+ function createGmailSmtpProvider(deps) {
52977
+ const http = deps.fetch ?? fetch;
52978
+ let lastVerifiedAt;
52979
+ let lastError;
52980
+ const status = () => {
52981
+ const cfg = deps.getConfig();
52982
+ const email = deps.getAccountEmail() || cfg.fromAddress.trim() || "gmail";
52983
+ if (!cfg.gmailSendEnabled) return {
52984
+ ready: false,
52985
+ host: email,
52986
+ error: "Gmail send is disabled in Google Services settings.",
52987
+ lastVerifiedAt
52988
+ };
52989
+ if (!deps.connected()) return {
52990
+ ready: false,
52991
+ host: email,
52992
+ error: "Google account is not connected — open Connect Google.",
52993
+ lastVerifiedAt
52994
+ };
52995
+ return {
52996
+ ready: lastError === void 0 && lastVerifiedAt !== void 0,
52997
+ host: email,
52998
+ error: lastError,
52999
+ lastVerifiedAt
53000
+ };
53001
+ };
53002
+ return {
53003
+ getStatus: async () => status(),
53004
+ verify: async () => {
53005
+ if (!deps.getConfig().gmailSendEnabled) {
53006
+ lastError = "Gmail send is disabled in Google Services settings.";
53007
+ return status();
53008
+ }
53009
+ if (!deps.connected()) {
53010
+ lastError = "Google account is not connected.";
53011
+ return status();
53012
+ }
53013
+ try {
53014
+ const token = await deps.getAccessToken();
53015
+ const res = await http(`${GOOGLE_GMAIL_API}/users/me/profile`, { headers: { Authorization: `Bearer ${token}` } });
53016
+ if (!res.ok) {
53017
+ const text = await res.text();
53018
+ lastError = `Gmail profile failed (${res.status}): ${text.slice(0, 200)}`;
53019
+ return status();
53020
+ }
53021
+ lastError = void 0;
53022
+ lastVerifiedAt = Date.now();
53023
+ return status();
53024
+ } catch (err) {
53025
+ lastError = err instanceof Error ? err.message : String(err);
53026
+ return status();
53027
+ }
53028
+ },
53029
+ sendEmail: async (input) => {
53030
+ const cfg = deps.getConfig();
53031
+ if (!cfg.gmailSendEnabled) throw new Error("Gmail send is disabled in Google Services settings.");
53032
+ if (!deps.connected()) throw new Error("Google account is not connected — open Connect Google.");
53033
+ const from = input.from?.trim() || buildFromHeader(cfg, deps.getAccountEmail());
53034
+ if (!from) throw new Error("Set From address in Google Services settings (or connect an account).");
53035
+ const raw = buildRawMime({
53036
+ from,
53037
+ to: input.to,
53038
+ cc: input.cc,
53039
+ bcc: input.bcc,
53040
+ replyTo: input.replyTo,
53041
+ subject: input.subject,
53042
+ text: input.text,
53043
+ html: input.html
53044
+ });
53045
+ const token = await deps.getAccessToken();
53046
+ const res = await http(`${GOOGLE_GMAIL_API}/users/me/messages/send`, {
53047
+ method: "POST",
53048
+ headers: {
53049
+ Authorization: `Bearer ${token}`,
53050
+ "Content-Type": "application/json"
53051
+ },
53052
+ body: JSON.stringify({ raw: encodeBase64Url(Buffer.from(raw, "utf8")) })
53053
+ });
53054
+ if (!res.ok) {
53055
+ const text = await res.text();
53056
+ throw new Error(`Gmail send failed (${res.status}): ${text.slice(0, 400)}`);
53057
+ }
53058
+ const messageId = (await res.json()).id ?? `gmail-${Date.now()}`;
53059
+ lastError = void 0;
53060
+ lastVerifiedAt = Date.now();
53061
+ return {
53062
+ messageId,
53063
+ accepted: [...Array.isArray(input.to) ? input.to : [input.to]],
53064
+ rejected: []
53065
+ };
53066
+ }
53067
+ };
53068
+ }
53069
+ //#endregion
52858
53070
  //#region src/google-oauth.ts
52859
53071
  /**
52860
53072
  * Google OAuth2 authorization-code + refresh for Calendar API.
@@ -52864,7 +53076,7 @@ function buildAuthorizeUrl(cfg, state) {
52864
53076
  u.searchParams.set("client_id", cfg.clientId);
52865
53077
  u.searchParams.set("redirect_uri", cfg.redirectUri);
52866
53078
  u.searchParams.set("response_type", "code");
52867
- u.searchParams.set("scope", GOOGLE_CALENDAR_SCOPE);
53079
+ u.searchParams.set("scope", GOOGLE_OAUTH_SCOPES);
52868
53080
  u.searchParams.set("access_type", "offline");
52869
53081
  u.searchParams.set("prompt", "consent");
52870
53082
  u.searchParams.set("state", state);
@@ -52922,9 +53134,19 @@ function needsRefresh(state, nowMs = Date.now()) {
52922
53134
  return state.accessExpiresAt <= nowMs;
52923
53135
  }
52924
53136
  //#endregion
53137
+ //#region src/oauth-redirect-origin.ts
53138
+ /**
53139
+ * Google OAuth redirect origin — same role as hub OAuth2 / sign-in
53140
+ * (`local-network` cap: `roles.publicEndpoint`), never `internal` (LAN / agents).
53141
+ * Google rejects non-localhost `http://` redirect URIs for Web clients.
53142
+ */
53143
+ function pickOauthRedirectOrigin(book) {
53144
+ return book.roles.publicEndpoint.resolved ?? book.roles.internal.resolved ?? null;
53145
+ }
53146
+ //#endregion
52925
53147
  //#region src/google-services.addon.ts
52926
53148
  /**
52927
- * Google Services — hub-only calendar-source over Google Calendar API (OAuth2).
53149
+ * Google Services — hub-only calendar-source + Gmail smtp-provider (same OAuth2).
52928
53150
  */
52929
53151
  var ADDON_ID = "google-services";
52930
53152
  var CALLBACK_PATH = "/oauth/callback";
@@ -52948,17 +53170,23 @@ var GoogleServicesAddon = class extends BaseAddon {
52948
53170
  this.oauthHandle = this.state("oauth", OauthStateSchema, { ...DEFAULT_OAUTH_STATE });
52949
53171
  this.oauth = await this.oauthHandle.get();
52950
53172
  const api = createGoogleCalendarApi(async () => this.ensureAccessToken());
52951
- const provider = createGoogleCalendarSource({
53173
+ const calendarProvider = createGoogleCalendarSource({
52952
53174
  logger: this.ctx.logger,
52953
53175
  api,
52954
53176
  connected: () => this.oauth.refreshToken.length > 0,
52955
53177
  filterIds: () => this.parseFilter()
52956
53178
  });
53179
+ const smtpProvider = createGmailSmtpProvider({
53180
+ getAccessToken: () => this.ensureAccessToken(),
53181
+ getConfig: () => this.config,
53182
+ getAccountEmail: () => this.oauth.accountEmail,
53183
+ connected: () => this.oauth.refreshToken.length > 0
53184
+ });
52957
53185
  const routeProvider = buildAddonRouteProvider(ADDON_ID, [{
52958
53186
  method: "GET",
52959
53187
  path: START_PATH,
52960
53188
  access: "admin",
52961
- description: "Begin Google Calendar OAuth",
53189
+ description: "Begin Google OAuth (Calendar + Gmail send)",
52962
53190
  handler: async (_req, reply) => this.handleStart(reply)
52963
53191
  }, {
52964
53192
  method: "GET",
@@ -52968,17 +53196,25 @@ var GoogleServicesAddon = class extends BaseAddon {
52968
53196
  description: "Google OAuth callback",
52969
53197
  handler: async (req, reply) => this.handleCallback(req, reply)
52970
53198
  }]);
52971
- this.ctx.logger.info("Initialized — google calendar-source", { meta: {
53199
+ this.ctx.logger.info("Initialized — google calendar-source + gmail smtp", { meta: {
52972
53200
  connected: this.oauth.refreshToken.length > 0,
52973
- accountEmail: this.oauth.accountEmail || void 0
53201
+ accountEmail: this.oauth.accountEmail || void 0,
53202
+ gmailSendEnabled: this.config.gmailSendEnabled
52974
53203
  } });
52975
- return { providers: [{
52976
- capability: calendarSourceCapability,
52977
- provider
52978
- }, {
52979
- capability: addonRoutesCapability,
52980
- provider: routeProvider
52981
- }] };
53204
+ return { providers: [
53205
+ {
53206
+ capability: calendarSourceCapability,
53207
+ provider: calendarProvider
53208
+ },
53209
+ {
53210
+ capability: smtpProviderCapability,
53211
+ provider: smtpProvider
53212
+ },
53213
+ {
53214
+ capability: addonRoutesCapability,
53215
+ provider: routeProvider
53216
+ }
53217
+ ] };
52982
53218
  }
52983
53219
  async handleStart(reply) {
52984
53220
  if (!this.config.clientId || !this.config.clientSecret) {
@@ -53024,15 +53260,33 @@ var GoogleServicesAddon = class extends BaseAddon {
53024
53260
  }, code);
53025
53261
  if (!tokens.refresh_token && !this.oauth.refreshToken) throw new Error("Google did not return a refresh_token. Revoke CamStack access in Google Account → Security and retry with prompt=consent.");
53026
53262
  this.oauth = applyTokenResponse(this.oauth, tokens);
53263
+ const email = await this.fetchAccountEmail(this.oauth.accessToken);
53264
+ if (email) this.oauth = {
53265
+ ...this.oauth,
53266
+ accountEmail: email
53267
+ };
53027
53268
  await this.persistOauth();
53028
- this.ctx.logger.info("google oauth connected", { meta: { hasRefresh: this.oauth.refreshToken.length > 0 } });
53029
- this.html(reply, 200, "Google Calendar connected. You can close this window.");
53269
+ this.ctx.logger.info("google oauth connected", { meta: {
53270
+ hasRefresh: this.oauth.refreshToken.length > 0,
53271
+ accountEmail: this.oauth.accountEmail || void 0
53272
+ } });
53273
+ this.html(reply, 200, "Google account connected (Calendar + Gmail send). You can close this window.");
53030
53274
  } catch (e) {
53031
53275
  const message = e instanceof Error ? e.message : String(e);
53032
53276
  this.ctx.logger.warn("google oauth callback failed", { meta: { error: message } });
53033
53277
  this.html(reply, 500, `OAuth failed: ${message}`);
53034
53278
  }
53035
53279
  }
53280
+ async fetchAccountEmail(accessToken) {
53281
+ try {
53282
+ const res = await fetch(GOOGLE_USERINFO_URL, { headers: { Authorization: `Bearer ${accessToken}` } });
53283
+ if (!res.ok) return "";
53284
+ const body = await res.json();
53285
+ return typeof body.email === "string" ? body.email.trim() : "";
53286
+ } catch {
53287
+ return "";
53288
+ }
53289
+ }
53036
53290
  html(reply, code, body) {
53037
53291
  reply.header("content-type", "text/html; charset=utf-8");
53038
53292
  reply.status(code).send(`<!doctype html><html><body><p>${escapeHtml(body)}</p></body></html>`);
@@ -53062,16 +53316,15 @@ var GoogleServicesAddon = class extends BaseAddon {
53062
53316
  return new Set(raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0));
53063
53317
  }
53064
53318
  async resolveRedirectUri() {
53065
- const book = await this.ctx.api.localNetwork.getAddresses.query({});
53066
- const origin = book.roles.internal.resolved ?? book.roles.publicEndpoint.resolved ?? null;
53067
- if (!origin) throw new Error("No hub address available from local-network — set an Internal address on the Network page.");
53319
+ const origin = pickOauthRedirectOrigin(await this.ctx.api.localNetwork.getAddresses.query({}));
53320
+ if (!origin) throw new Error("No hub public address available from local-network — set a Public endpoint on the Network page (OAuth2 redirects use that role, not Internal).");
53068
53321
  return `${origin.replace(/\/$/, "")}/addon/${ADDON_ID}${CALLBACK_PATH}`;
53069
53322
  }
53070
53323
  globalSettingsSchema() {
53071
53324
  return this.schema({ sections: [{
53072
53325
  id: "google-oauth",
53073
- title: "Google Calendar OAuth",
53074
- description: "Create an OAuth client (Web application) in Google Cloud Console with the Calendar API enabled. Register the redirect URI `<hub>/addon/google-services/oauth/callback` (hub = Internal address from Network).",
53326
+ title: "Google OAuth",
53327
+ description: "Create an OAuth client (Web application) in Google Cloud Console with the Calendar API and Gmail API enabled. Connect grants both Calendar read and Gmail send on the same account. Register the redirect URI `<public-hub>/addon/google-services/oauth/callback` — hub = Public endpoint from Network (same role as sign-in / OAuth2; Google rejects plain LAN http).",
53075
53328
  columns: 1,
53076
53329
  fields: [
53077
53330
  {
@@ -53096,7 +53349,31 @@ var GoogleServicesAddon = class extends BaseAddon {
53096
53349
  key: "_connectInfo",
53097
53350
  label: "Connect",
53098
53351
  format: "html",
53099
- content: `After saving client id/secret, open <a href="/addon/${ADDON_ID}${START_PATH}">Connect Google Calendar</a> while signed in as admin.`
53352
+ content: `After saving client id/secret, open <a href="/addon/${ADDON_ID}${START_PATH}">Connect Google</a> while signed in as admin. Redirect URI uses the Network <b>Public endpoint</b> (today typically <code>https://…/addon/google-services/oauth/callback</code>). If you connected before Gmail send was added, revoke CamStack in Google Account security and reconnect so the refresh token includes <code>gmail.send</code>.`
53353
+ }
53354
+ ]
53355
+ }, {
53356
+ id: "gmail-send",
53357
+ title: "Gmail send",
53358
+ description: "Optional email delivery via the Gmail API (`smtp-provider`). Uses the same OAuth account — From must be that account or a verified send-as alias.",
53359
+ columns: 1,
53360
+ fields: [
53361
+ {
53362
+ type: "boolean",
53363
+ key: "gmailSendEnabled",
53364
+ label: "Enable Gmail send",
53365
+ description: "When off, Calendar still works; this addon reports smtp-provider not ready."
53366
+ },
53367
+ {
53368
+ type: "text",
53369
+ key: "fromAddress",
53370
+ label: "From address",
53371
+ description: "Defaults to the connected Google account email when empty."
53372
+ },
53373
+ {
53374
+ type: "text",
53375
+ key: "fromName",
53376
+ label: "From display name (optional)"
53100
53377
  }
53101
53378
  ]
53102
53379
  }] });
@@ -53106,4 +53383,4 @@ function escapeHtml(s) {
53106
53383
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
53107
53384
  }
53108
53385
  //#endregion
53109
- export { GoogleServicesAddon, GoogleServicesAddon as default, refreshAccessToken as a, mapGoogleEvent as c, needsRefresh as i, DEFAULT_GOOGLE_CONFIG as l, buildAuthorizeUrl as n, createGoogleCalendarApi as o, exchangeCode as r, createGoogleCalendarSource as s, applyTokenResponse as t, GoogleServicesConfigSchema as u };
53386
+ export { GoogleServicesAddon, GoogleServicesAddon as default, refreshAccessToken as a, createGoogleCalendarApi as c, DEFAULT_GOOGLE_CONFIG as d, GOOGLE_GMAIL_SEND_SCOPE as f, needsRefresh as i, createGoogleCalendarSource as l, GoogleServicesConfigSchema as m, buildAuthorizeUrl as n, buildRawMime as o, GOOGLE_OAUTH_SCOPES as p, exchangeCode as r, createGmailSmtpProvider as s, applyTokenResponse as t, mapGoogleEvent as u };
package/dist/index.js CHANGED
@@ -4,10 +4,14 @@ Object.defineProperties(exports, {
4
4
  });
5
5
  const require_google_services_addon = require("./google-services.addon.js");
6
6
  exports.DEFAULT_GOOGLE_CONFIG = require_google_services_addon.DEFAULT_GOOGLE_CONFIG;
7
+ exports.GOOGLE_GMAIL_SEND_SCOPE = require_google_services_addon.GOOGLE_GMAIL_SEND_SCOPE;
8
+ exports.GOOGLE_OAUTH_SCOPES = require_google_services_addon.GOOGLE_OAUTH_SCOPES;
7
9
  exports.GoogleServicesAddon = require_google_services_addon.GoogleServicesAddon;
8
10
  exports.GoogleServicesConfigSchema = require_google_services_addon.GoogleServicesConfigSchema;
9
11
  exports.applyTokenResponse = require_google_services_addon.applyTokenResponse;
10
12
  exports.buildAuthorizeUrl = require_google_services_addon.buildAuthorizeUrl;
13
+ exports.buildRawMime = require_google_services_addon.buildRawMime;
14
+ exports.createGmailSmtpProvider = require_google_services_addon.createGmailSmtpProvider;
11
15
  exports.createGoogleCalendarApi = require_google_services_addon.createGoogleCalendarApi;
12
16
  exports.createGoogleCalendarSource = require_google_services_addon.createGoogleCalendarSource;
13
17
  exports.default = require_google_services_addon.GoogleServicesAddon;
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { GoogleServicesAddon, a as refreshAccessToken, c as mapGoogleEvent, i as needsRefresh, l as DEFAULT_GOOGLE_CONFIG, n as buildAuthorizeUrl, o as createGoogleCalendarApi, r as exchangeCode, s as createGoogleCalendarSource, t as applyTokenResponse, u as GoogleServicesConfigSchema } from "./google-services.addon.mjs";
2
- export { DEFAULT_GOOGLE_CONFIG, GoogleServicesAddon, GoogleServicesAddon as default, GoogleServicesConfigSchema, applyTokenResponse, buildAuthorizeUrl, createGoogleCalendarApi, createGoogleCalendarSource, exchangeCode, mapGoogleEvent, needsRefresh, refreshAccessToken };
1
+ import { GoogleServicesAddon, a as refreshAccessToken, c as createGoogleCalendarApi, d as DEFAULT_GOOGLE_CONFIG, f as GOOGLE_GMAIL_SEND_SCOPE, i as needsRefresh, l as createGoogleCalendarSource, m as GoogleServicesConfigSchema, n as buildAuthorizeUrl, o as buildRawMime, p as GOOGLE_OAUTH_SCOPES, r as exchangeCode, s as createGmailSmtpProvider, t as applyTokenResponse, u as mapGoogleEvent } from "./google-services.addon.mjs";
2
+ export { DEFAULT_GOOGLE_CONFIG, GOOGLE_GMAIL_SEND_SCOPE, GOOGLE_OAUTH_SCOPES, GoogleServicesAddon, GoogleServicesAddon as default, GoogleServicesConfigSchema, applyTokenResponse, buildAuthorizeUrl, buildRawMime, createGmailSmtpProvider, createGoogleCalendarApi, createGoogleCalendarSource, exchangeCode, mapGoogleEvent, needsRefresh, refreshAccessToken };
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@camstack/addon-google-services",
3
- "version": "0.1.1",
4
- "description": "Google Services — Google Calendar as a CamStack calendar-source via OAuth2.",
3
+ "version": "0.1.3",
4
+ "description": "Google Services — Google Calendar (calendar-source) and Gmail send (smtp-provider) via one OAuth2 account.",
5
5
  "keywords": [
6
6
  "camstack",
7
7
  "addon",
8
8
  "camstack-addon",
9
9
  "google",
10
10
  "calendar",
11
+ "gmail",
11
12
  "oauth"
12
13
  ],
13
14
  "license": "MIT",
@@ -27,7 +28,7 @@
27
28
  "id": "google-services",
28
29
  "category": "messaging",
29
30
  "name": "Google Services",
30
- "description": "Connects a Google account (OAuth2) and exposes Google Calendar as a calendar-source for System → Calendars and notification rules.",
31
+ "description": "Connects a Google account (OAuth2): Calendar as calendar-source, optional Gmail send as smtp-provider.",
31
32
  "entry": "./dist/google-services.addon.js",
32
33
  "execution": {
33
34
  "placement": "hub-only"
@@ -36,6 +37,9 @@
36
37
  {
37
38
  "name": "calendar-source"
38
39
  },
40
+ {
41
+ "name": "smtp-provider"
42
+ },
39
43
  {
40
44
  "name": "addon-routes"
41
45
  }