@camstack/addon-google-services 0.1.2 → 0.1.4

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" })
@@ -39488,6 +39502,12 @@ var TrainingProviderDefaultsSchema = object({
39488
39502
  /** Hours a FAILED job's cloud dataset is kept for debugging; 0 = delete at once. */
39489
39503
  failedDatasetGraceHours: number().nonnegative()
39490
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();
39491
39511
  /** One creatable provider type, for the Add-entry dialog. */
39492
39512
  var TrainingProviderTypeSchema = object({
39493
39513
  typeId: string(),
@@ -39495,7 +39515,13 @@ var TrainingProviderTypeSchema = object({
39495
39515
  /** Path under the addon package (e.g. `assets/training-modal/icon.svg`). */
39496
39516
  iconAsset: string(),
39497
39517
  /** Owning `training-provider` addon — collection selector for createEntry. */
39498
- 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()
39499
39525
  });
39500
39526
  /** One configured account / workspace under a provider type. */
39501
39527
  var TrainingProviderEntrySchema = object({
@@ -39527,24 +39553,11 @@ var TrainingProviderDescriptorSchema = object({
39527
39553
  configured: boolean(),
39528
39554
  defaults: TrainingProviderDefaultsSchema,
39529
39555
  /**
39530
- * Non-secret account fields for the Providers form (e.g. Modal token id).
39531
- * The secret itself never appears — only a keychain {@link SecretFieldView}
39532
- * (or `hasTokenSecret` for older clients).
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).
39533
39559
  */
39534
- tokenId: string().optional(),
39535
- environment: string().optional(),
39536
- hasTokenSecret: boolean().optional(),
39537
- /**
39538
- * Keychain view for the entry's token secret — for `SecretFieldControl` on
39539
- * edit. Create has no view (`null` / omitted); the form uses a plain
39540
- * `PasswordInput` instead.
39541
- */
39542
- tokenSecretView: object({
39543
- $secret: string().nullable(),
39544
- v: literal(1),
39545
- set: literal(true),
39546
- inline: literal(true).optional()
39547
- }).nullable().optional()
39560
+ formValues: record(string(), unknown()).optional()
39548
39561
  });
39549
39562
  /**
39550
39563
  * Patch one entry's name, credentials and defaults. Pin with owning `{ addonId }`.
@@ -51746,6 +51759,7 @@ Object.freeze({
51746
51759
  });
51747
51760
  Object.freeze({
51748
51761
  "broker": "broker",
51762
+ "calendar-source": "calendar",
51749
51763
  "device-export": "device-export",
51750
51764
  "llm": "ai",
51751
51765
  "mesh-network": "mesh",
@@ -52790,7 +52804,12 @@ var GoogleServicesConfigSchema = object({
52790
52804
  clientId: string().default(""),
52791
52805
  clientSecret: string().default("").meta({ secret: true }),
52792
52806
  /** Comma-separated Google calendar ids to expose; empty = all. */
52793
- 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("")
52794
52813
  });
52795
52814
  var DEFAULT_GOOGLE_CONFIG = GoogleServicesConfigSchema.parse({});
52796
52815
  var DEFAULT_OAUTH_STATE = {
@@ -52800,9 +52819,14 @@ var DEFAULT_OAUTH_STATE = {
52800
52819
  accountEmail: ""
52801
52820
  };
52802
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}`;
52803
52825
  var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
52804
52826
  var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
52805
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";
52806
52830
  //#endregion
52807
52831
  //#region src/google-calendar-source.ts
52808
52832
  function parseGoogleInstant(value) {
@@ -52905,6 +52929,148 @@ function createGoogleCalendarSource(deps) {
52905
52929
  };
52906
52930
  }
52907
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
52908
53074
  //#region src/google-oauth.ts
52909
53075
  /**
52910
53076
  * Google OAuth2 authorization-code + refresh for Calendar API.
@@ -52914,7 +53080,7 @@ function buildAuthorizeUrl(cfg, state) {
52914
53080
  u.searchParams.set("client_id", cfg.clientId);
52915
53081
  u.searchParams.set("redirect_uri", cfg.redirectUri);
52916
53082
  u.searchParams.set("response_type", "code");
52917
- u.searchParams.set("scope", GOOGLE_CALENDAR_SCOPE);
53083
+ u.searchParams.set("scope", GOOGLE_OAUTH_SCOPES);
52918
53084
  u.searchParams.set("access_type", "offline");
52919
53085
  u.searchParams.set("prompt", "consent");
52920
53086
  u.searchParams.set("state", state);
@@ -52984,7 +53150,7 @@ function pickOauthRedirectOrigin(book) {
52984
53150
  //#endregion
52985
53151
  //#region src/google-services.addon.ts
52986
53152
  /**
52987
- * Google Services — hub-only calendar-source over Google Calendar API (OAuth2).
53153
+ * Google Services — hub-only calendar-source + Gmail smtp-provider (same OAuth2).
52988
53154
  */
52989
53155
  var ADDON_ID = "google-services";
52990
53156
  var CALLBACK_PATH = "/oauth/callback";
@@ -53008,17 +53174,23 @@ var GoogleServicesAddon = class extends BaseAddon {
53008
53174
  this.oauthHandle = this.state("oauth", OauthStateSchema, { ...DEFAULT_OAUTH_STATE });
53009
53175
  this.oauth = await this.oauthHandle.get();
53010
53176
  const api = createGoogleCalendarApi(async () => this.ensureAccessToken());
53011
- const provider = createGoogleCalendarSource({
53177
+ const calendarProvider = createGoogleCalendarSource({
53012
53178
  logger: this.ctx.logger,
53013
53179
  api,
53014
53180
  connected: () => this.oauth.refreshToken.length > 0,
53015
53181
  filterIds: () => this.parseFilter()
53016
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
+ });
53017
53189
  const routeProvider = buildAddonRouteProvider(ADDON_ID, [{
53018
53190
  method: "GET",
53019
53191
  path: START_PATH,
53020
53192
  access: "admin",
53021
- description: "Begin Google Calendar OAuth",
53193
+ description: "Begin Google OAuth (Calendar + Gmail send)",
53022
53194
  handler: async (_req, reply) => this.handleStart(reply)
53023
53195
  }, {
53024
53196
  method: "GET",
@@ -53028,17 +53200,25 @@ var GoogleServicesAddon = class extends BaseAddon {
53028
53200
  description: "Google OAuth callback",
53029
53201
  handler: async (req, reply) => this.handleCallback(req, reply)
53030
53202
  }]);
53031
- this.ctx.logger.info("Initialized — google calendar-source", { meta: {
53203
+ this.ctx.logger.info("Initialized — google calendar-source + gmail smtp", { meta: {
53032
53204
  connected: this.oauth.refreshToken.length > 0,
53033
- accountEmail: this.oauth.accountEmail || void 0
53205
+ accountEmail: this.oauth.accountEmail || void 0,
53206
+ gmailSendEnabled: this.config.gmailSendEnabled
53034
53207
  } });
53035
- return { providers: [{
53036
- capability: calendarSourceCapability,
53037
- provider
53038
- }, {
53039
- capability: addonRoutesCapability,
53040
- provider: routeProvider
53041
- }] };
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
+ ] };
53042
53222
  }
53043
53223
  async handleStart(reply) {
53044
53224
  if (!this.config.clientId || !this.config.clientSecret) {
@@ -53084,15 +53264,33 @@ var GoogleServicesAddon = class extends BaseAddon {
53084
53264
  }, code);
53085
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.");
53086
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
+ };
53087
53272
  await this.persistOauth();
53088
- this.ctx.logger.info("google oauth connected", { meta: { hasRefresh: this.oauth.refreshToken.length > 0 } });
53089
- 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.");
53090
53278
  } catch (e) {
53091
53279
  const message = e instanceof Error ? e.message : String(e);
53092
53280
  this.ctx.logger.warn("google oauth callback failed", { meta: { error: message } });
53093
53281
  this.html(reply, 500, `OAuth failed: ${message}`);
53094
53282
  }
53095
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
+ }
53096
53294
  html(reply, code, body) {
53097
53295
  reply.header("content-type", "text/html; charset=utf-8");
53098
53296
  reply.status(code).send(`<!doctype html><html><body><p>${escapeHtml(body)}</p></body></html>`);
@@ -53126,39 +53324,89 @@ var GoogleServicesAddon = class extends BaseAddon {
53126
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).");
53127
53325
  return `${origin.replace(/\/$/, "")}/addon/${ADDON_ID}${CALLBACK_PATH}`;
53128
53326
  }
53129
- globalSettingsSchema() {
53130
- return this.schema({ sections: [{
53131
- id: "google-oauth",
53132
- title: "Google Calendar OAuth",
53133
- description: "Create an OAuth client (Web application) in Google Cloud Console with the Calendar API enabled. 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).",
53134
- columns: 1,
53135
- fields: [
53136
- {
53137
- type: "text",
53138
- key: "clientId",
53139
- label: "Client ID"
53140
- },
53141
- {
53142
- type: "password",
53143
- key: "clientSecret",
53144
- label: "Client secret",
53145
- showToggle: true
53146
- },
53147
- {
53327
+ /**
53328
+ * Connectivity asks for settings scoped to the page capability, so Calendar
53329
+ * and Email do not each redraw the other's fields (same pattern as
53330
+ * mqtt-broker under `broker`). The addon page (no cap) keeps every section.
53331
+ */
53332
+ globalSettingsSchema(cap) {
53333
+ const schema = this.fullSettingsSchema();
53334
+ if (cap === "calendar-source") return {
53335
+ ...schema,
53336
+ sections: schema.sections.filter((s) => s.id === "google-oauth" || s.id === "google-calendar")
53337
+ };
53338
+ if (cap === "smtp-provider") return {
53339
+ ...schema,
53340
+ sections: schema.sections.filter((s) => s.id === "google-oauth" || s.id === "gmail-send")
53341
+ };
53342
+ return schema;
53343
+ }
53344
+ fullSettingsSchema() {
53345
+ return this.schema({ sections: [
53346
+ {
53347
+ id: "google-oauth",
53348
+ title: "Google account",
53349
+ 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).",
53350
+ columns: 1,
53351
+ fields: [
53352
+ {
53353
+ type: "text",
53354
+ key: "clientId",
53355
+ label: "Client ID"
53356
+ },
53357
+ {
53358
+ type: "password",
53359
+ key: "clientSecret",
53360
+ label: "Client secret",
53361
+ showToggle: true
53362
+ },
53363
+ {
53364
+ type: "info",
53365
+ key: "_connectInfo",
53366
+ label: "Connect",
53367
+ format: "html",
53368
+ 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>.`
53369
+ }
53370
+ ]
53371
+ },
53372
+ {
53373
+ id: "google-calendar",
53374
+ title: "Google Calendar",
53375
+ description: "Which calendars this hub exposes as calendar-source entries.",
53376
+ columns: 1,
53377
+ fields: [{
53148
53378
  type: "text",
53149
53379
  key: "calendarFilter",
53150
53380
  label: "Calendar id filter (optional)",
53151
53381
  description: "Comma-separated Google calendar ids to expose. Empty = all calendars the account can read."
53152
- },
53153
- {
53154
- type: "info",
53155
- key: "_connectInfo",
53156
- label: "Connect",
53157
- format: "html",
53158
- content: `After saving client id/secret, open <a href="/addon/${ADDON_ID}${START_PATH}">Connect Google Calendar</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>).`
53159
- }
53160
- ]
53161
- }] });
53382
+ }]
53383
+ },
53384
+ {
53385
+ id: "gmail-send",
53386
+ title: "Gmail send",
53387
+ 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.",
53388
+ columns: 1,
53389
+ fields: [
53390
+ {
53391
+ type: "boolean",
53392
+ key: "gmailSendEnabled",
53393
+ label: "Enable Gmail send",
53394
+ description: "When off, Calendar still works; this addon reports smtp-provider not ready."
53395
+ },
53396
+ {
53397
+ type: "text",
53398
+ key: "fromAddress",
53399
+ label: "From address",
53400
+ description: "Defaults to the connected Google account email when empty."
53401
+ },
53402
+ {
53403
+ type: "text",
53404
+ key: "fromName",
53405
+ label: "From display name (optional)"
53406
+ }
53407
+ ]
53408
+ }
53409
+ ] });
53162
53410
  }
53163
53411
  };
53164
53412
  function escapeHtml(s) {
@@ -53166,11 +53414,15 @@ function escapeHtml(s) {
53166
53414
  }
53167
53415
  //#endregion
53168
53416
  exports.DEFAULT_GOOGLE_CONFIG = DEFAULT_GOOGLE_CONFIG;
53417
+ exports.GOOGLE_GMAIL_SEND_SCOPE = GOOGLE_GMAIL_SEND_SCOPE;
53418
+ exports.GOOGLE_OAUTH_SCOPES = GOOGLE_OAUTH_SCOPES;
53169
53419
  exports.GoogleServicesAddon = GoogleServicesAddon;
53170
53420
  exports.default = GoogleServicesAddon;
53171
53421
  exports.GoogleServicesConfigSchema = GoogleServicesConfigSchema;
53172
53422
  exports.applyTokenResponse = applyTokenResponse;
53173
53423
  exports.buildAuthorizeUrl = buildAuthorizeUrl;
53424
+ exports.buildRawMime = buildRawMime;
53425
+ exports.createGmailSmtpProvider = createGmailSmtpProvider;
53174
53426
  exports.createGoogleCalendarApi = createGoogleCalendarApi;
53175
53427
  exports.createGoogleCalendarSource = createGoogleCalendarSource;
53176
53428
  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" })
@@ -39484,6 +39498,12 @@ var TrainingProviderDefaultsSchema = object({
39484
39498
  /** Hours a FAILED job's cloud dataset is kept for debugging; 0 = delete at once. */
39485
39499
  failedDatasetGraceHours: number().nonnegative()
39486
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();
39487
39507
  /** One creatable provider type, for the Add-entry dialog. */
39488
39508
  var TrainingProviderTypeSchema = object({
39489
39509
  typeId: string(),
@@ -39491,7 +39511,13 @@ var TrainingProviderTypeSchema = object({
39491
39511
  /** Path under the addon package (e.g. `assets/training-modal/icon.svg`). */
39492
39512
  iconAsset: string(),
39493
39513
  /** Owning `training-provider` addon — collection selector for createEntry. */
39494
- 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()
39495
39521
  });
39496
39522
  /** One configured account / workspace under a provider type. */
39497
39523
  var TrainingProviderEntrySchema = object({
@@ -39523,24 +39549,11 @@ var TrainingProviderDescriptorSchema = object({
39523
39549
  configured: boolean(),
39524
39550
  defaults: TrainingProviderDefaultsSchema,
39525
39551
  /**
39526
- * Non-secret account fields for the Providers form (e.g. Modal token id).
39527
- * The secret itself never appears — only a keychain {@link SecretFieldView}
39528
- * (or `hasTokenSecret` for older clients).
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).
39529
39555
  */
39530
- tokenId: string().optional(),
39531
- environment: string().optional(),
39532
- hasTokenSecret: boolean().optional(),
39533
- /**
39534
- * Keychain view for the entry's token secret — for `SecretFieldControl` on
39535
- * edit. Create has no view (`null` / omitted); the form uses a plain
39536
- * `PasswordInput` instead.
39537
- */
39538
- tokenSecretView: object({
39539
- $secret: string().nullable(),
39540
- v: literal(1),
39541
- set: literal(true),
39542
- inline: literal(true).optional()
39543
- }).nullable().optional()
39556
+ formValues: record(string(), unknown()).optional()
39544
39557
  });
39545
39558
  /**
39546
39559
  * Patch one entry's name, credentials and defaults. Pin with owning `{ addonId }`.
@@ -51742,6 +51755,7 @@ Object.freeze({
51742
51755
  });
51743
51756
  Object.freeze({
51744
51757
  "broker": "broker",
51758
+ "calendar-source": "calendar",
51745
51759
  "device-export": "device-export",
51746
51760
  "llm": "ai",
51747
51761
  "mesh-network": "mesh",
@@ -52786,7 +52800,12 @@ var GoogleServicesConfigSchema = object({
52786
52800
  clientId: string().default(""),
52787
52801
  clientSecret: string().default("").meta({ secret: true }),
52788
52802
  /** Comma-separated Google calendar ids to expose; empty = all. */
52789
- 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("")
52790
52809
  });
52791
52810
  var DEFAULT_GOOGLE_CONFIG = GoogleServicesConfigSchema.parse({});
52792
52811
  var DEFAULT_OAUTH_STATE = {
@@ -52796,9 +52815,14 @@ var DEFAULT_OAUTH_STATE = {
52796
52815
  accountEmail: ""
52797
52816
  };
52798
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}`;
52799
52821
  var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
52800
52822
  var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
52801
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";
52802
52826
  //#endregion
52803
52827
  //#region src/google-calendar-source.ts
52804
52828
  function parseGoogleInstant(value) {
@@ -52901,6 +52925,148 @@ function createGoogleCalendarSource(deps) {
52901
52925
  };
52902
52926
  }
52903
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
52904
53070
  //#region src/google-oauth.ts
52905
53071
  /**
52906
53072
  * Google OAuth2 authorization-code + refresh for Calendar API.
@@ -52910,7 +53076,7 @@ function buildAuthorizeUrl(cfg, state) {
52910
53076
  u.searchParams.set("client_id", cfg.clientId);
52911
53077
  u.searchParams.set("redirect_uri", cfg.redirectUri);
52912
53078
  u.searchParams.set("response_type", "code");
52913
- u.searchParams.set("scope", GOOGLE_CALENDAR_SCOPE);
53079
+ u.searchParams.set("scope", GOOGLE_OAUTH_SCOPES);
52914
53080
  u.searchParams.set("access_type", "offline");
52915
53081
  u.searchParams.set("prompt", "consent");
52916
53082
  u.searchParams.set("state", state);
@@ -52980,7 +53146,7 @@ function pickOauthRedirectOrigin(book) {
52980
53146
  //#endregion
52981
53147
  //#region src/google-services.addon.ts
52982
53148
  /**
52983
- * Google Services — hub-only calendar-source over Google Calendar API (OAuth2).
53149
+ * Google Services — hub-only calendar-source + Gmail smtp-provider (same OAuth2).
52984
53150
  */
52985
53151
  var ADDON_ID = "google-services";
52986
53152
  var CALLBACK_PATH = "/oauth/callback";
@@ -53004,17 +53170,23 @@ var GoogleServicesAddon = class extends BaseAddon {
53004
53170
  this.oauthHandle = this.state("oauth", OauthStateSchema, { ...DEFAULT_OAUTH_STATE });
53005
53171
  this.oauth = await this.oauthHandle.get();
53006
53172
  const api = createGoogleCalendarApi(async () => this.ensureAccessToken());
53007
- const provider = createGoogleCalendarSource({
53173
+ const calendarProvider = createGoogleCalendarSource({
53008
53174
  logger: this.ctx.logger,
53009
53175
  api,
53010
53176
  connected: () => this.oauth.refreshToken.length > 0,
53011
53177
  filterIds: () => this.parseFilter()
53012
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
+ });
53013
53185
  const routeProvider = buildAddonRouteProvider(ADDON_ID, [{
53014
53186
  method: "GET",
53015
53187
  path: START_PATH,
53016
53188
  access: "admin",
53017
- description: "Begin Google Calendar OAuth",
53189
+ description: "Begin Google OAuth (Calendar + Gmail send)",
53018
53190
  handler: async (_req, reply) => this.handleStart(reply)
53019
53191
  }, {
53020
53192
  method: "GET",
@@ -53024,17 +53196,25 @@ var GoogleServicesAddon = class extends BaseAddon {
53024
53196
  description: "Google OAuth callback",
53025
53197
  handler: async (req, reply) => this.handleCallback(req, reply)
53026
53198
  }]);
53027
- this.ctx.logger.info("Initialized — google calendar-source", { meta: {
53199
+ this.ctx.logger.info("Initialized — google calendar-source + gmail smtp", { meta: {
53028
53200
  connected: this.oauth.refreshToken.length > 0,
53029
- accountEmail: this.oauth.accountEmail || void 0
53201
+ accountEmail: this.oauth.accountEmail || void 0,
53202
+ gmailSendEnabled: this.config.gmailSendEnabled
53030
53203
  } });
53031
- return { providers: [{
53032
- capability: calendarSourceCapability,
53033
- provider
53034
- }, {
53035
- capability: addonRoutesCapability,
53036
- provider: routeProvider
53037
- }] };
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
+ ] };
53038
53218
  }
53039
53219
  async handleStart(reply) {
53040
53220
  if (!this.config.clientId || !this.config.clientSecret) {
@@ -53080,15 +53260,33 @@ var GoogleServicesAddon = class extends BaseAddon {
53080
53260
  }, code);
53081
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.");
53082
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
+ };
53083
53268
  await this.persistOauth();
53084
- this.ctx.logger.info("google oauth connected", { meta: { hasRefresh: this.oauth.refreshToken.length > 0 } });
53085
- 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.");
53086
53274
  } catch (e) {
53087
53275
  const message = e instanceof Error ? e.message : String(e);
53088
53276
  this.ctx.logger.warn("google oauth callback failed", { meta: { error: message } });
53089
53277
  this.html(reply, 500, `OAuth failed: ${message}`);
53090
53278
  }
53091
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
+ }
53092
53290
  html(reply, code, body) {
53093
53291
  reply.header("content-type", "text/html; charset=utf-8");
53094
53292
  reply.status(code).send(`<!doctype html><html><body><p>${escapeHtml(body)}</p></body></html>`);
@@ -53122,43 +53320,93 @@ var GoogleServicesAddon = class extends BaseAddon {
53122
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).");
53123
53321
  return `${origin.replace(/\/$/, "")}/addon/${ADDON_ID}${CALLBACK_PATH}`;
53124
53322
  }
53125
- globalSettingsSchema() {
53126
- return this.schema({ sections: [{
53127
- id: "google-oauth",
53128
- title: "Google Calendar OAuth",
53129
- description: "Create an OAuth client (Web application) in Google Cloud Console with the Calendar API enabled. 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).",
53130
- columns: 1,
53131
- fields: [
53132
- {
53133
- type: "text",
53134
- key: "clientId",
53135
- label: "Client ID"
53136
- },
53137
- {
53138
- type: "password",
53139
- key: "clientSecret",
53140
- label: "Client secret",
53141
- showToggle: true
53142
- },
53143
- {
53323
+ /**
53324
+ * Connectivity asks for settings scoped to the page capability, so Calendar
53325
+ * and Email do not each redraw the other's fields (same pattern as
53326
+ * mqtt-broker under `broker`). The addon page (no cap) keeps every section.
53327
+ */
53328
+ globalSettingsSchema(cap) {
53329
+ const schema = this.fullSettingsSchema();
53330
+ if (cap === "calendar-source") return {
53331
+ ...schema,
53332
+ sections: schema.sections.filter((s) => s.id === "google-oauth" || s.id === "google-calendar")
53333
+ };
53334
+ if (cap === "smtp-provider") return {
53335
+ ...schema,
53336
+ sections: schema.sections.filter((s) => s.id === "google-oauth" || s.id === "gmail-send")
53337
+ };
53338
+ return schema;
53339
+ }
53340
+ fullSettingsSchema() {
53341
+ return this.schema({ sections: [
53342
+ {
53343
+ id: "google-oauth",
53344
+ title: "Google account",
53345
+ 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).",
53346
+ columns: 1,
53347
+ fields: [
53348
+ {
53349
+ type: "text",
53350
+ key: "clientId",
53351
+ label: "Client ID"
53352
+ },
53353
+ {
53354
+ type: "password",
53355
+ key: "clientSecret",
53356
+ label: "Client secret",
53357
+ showToggle: true
53358
+ },
53359
+ {
53360
+ type: "info",
53361
+ key: "_connectInfo",
53362
+ label: "Connect",
53363
+ format: "html",
53364
+ 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>.`
53365
+ }
53366
+ ]
53367
+ },
53368
+ {
53369
+ id: "google-calendar",
53370
+ title: "Google Calendar",
53371
+ description: "Which calendars this hub exposes as calendar-source entries.",
53372
+ columns: 1,
53373
+ fields: [{
53144
53374
  type: "text",
53145
53375
  key: "calendarFilter",
53146
53376
  label: "Calendar id filter (optional)",
53147
53377
  description: "Comma-separated Google calendar ids to expose. Empty = all calendars the account can read."
53148
- },
53149
- {
53150
- type: "info",
53151
- key: "_connectInfo",
53152
- label: "Connect",
53153
- format: "html",
53154
- content: `After saving client id/secret, open <a href="/addon/${ADDON_ID}${START_PATH}">Connect Google Calendar</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>).`
53155
- }
53156
- ]
53157
- }] });
53378
+ }]
53379
+ },
53380
+ {
53381
+ id: "gmail-send",
53382
+ title: "Gmail send",
53383
+ 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.",
53384
+ columns: 1,
53385
+ fields: [
53386
+ {
53387
+ type: "boolean",
53388
+ key: "gmailSendEnabled",
53389
+ label: "Enable Gmail send",
53390
+ description: "When off, Calendar still works; this addon reports smtp-provider not ready."
53391
+ },
53392
+ {
53393
+ type: "text",
53394
+ key: "fromAddress",
53395
+ label: "From address",
53396
+ description: "Defaults to the connected Google account email when empty."
53397
+ },
53398
+ {
53399
+ type: "text",
53400
+ key: "fromName",
53401
+ label: "From display name (optional)"
53402
+ }
53403
+ ]
53404
+ }
53405
+ ] });
53158
53406
  }
53159
53407
  };
53160
53408
  function escapeHtml(s) {
53161
53409
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
53162
53410
  }
53163
53411
  //#endregion
53164
- 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 };
53412
+ 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.2",
4
- "description": "Google Services — Google Calendar as a CamStack calendar-source via OAuth2.",
3
+ "version": "0.1.4",
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
  }