fastsmtp-cli 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,469 @@
1
+ """RuleSet and rule management commands.
2
+
3
+ Rulesets and rules are nested under a domain on the server, so every command
4
+ here takes the domain ID first. Rules have no name, no per-rule ``priority`` and
5
+ no ``is_enabled``: their evaluation order is the ruleset's ``order``, changed
6
+ with ``fsmtp rules rule reorder``.
7
+ """
8
+
9
+ from typing import Annotated
10
+
11
+ import typer
12
+
13
+ from fastsmtp_cli.client import APIError, FastSMTPClient
14
+ from fastsmtp_cli.commands.options import clearable_str
15
+ from fastsmtp_cli.output import (
16
+ print_error,
17
+ print_rule,
18
+ print_rules_table,
19
+ print_ruleset,
20
+ print_rulesets_table,
21
+ print_success,
22
+ )
23
+
24
+ app = typer.Typer(help="RuleSet and rule management")
25
+
26
+ # Mirrors fastsmtp.schemas.rule - the server rejects anything else.
27
+ VALID_FIELDS = (
28
+ "from",
29
+ "to",
30
+ "subject",
31
+ "body",
32
+ "has_attachment",
33
+ "dkim_result",
34
+ "spf_result",
35
+ )
36
+ FIELD_HEADER_PREFIX = "header:"
37
+ VALID_OPERATORS = ("equals", "contains", "regex", "starts_with", "ends_with", "exists")
38
+ VALID_ACTIONS = ("forward", "drop", "tag", "quarantine")
39
+
40
+
41
+ def _validate_field(field: str | None) -> None:
42
+ """Reject fields the server would reject, before spending a round-trip."""
43
+ if field is None or field in VALID_FIELDS or field.startswith(FIELD_HEADER_PREFIX):
44
+ return
45
+ print_error(
46
+ f"Invalid field. Must be one of: {', '.join(VALID_FIELDS)}, "
47
+ f"or {FIELD_HEADER_PREFIX}X-Header-Name"
48
+ )
49
+ raise typer.Exit(1)
50
+
51
+
52
+ def _validate_operator(operator: str | None) -> None:
53
+ """Reject operators the server would reject."""
54
+ if operator is None or operator in VALID_OPERATORS:
55
+ return
56
+ print_error(f"Invalid operator. Must be one of: {', '.join(VALID_OPERATORS)}")
57
+ raise typer.Exit(1)
58
+
59
+
60
+ def _validate_action(action: str | None) -> None:
61
+ """Reject actions the server would reject."""
62
+ if action is None or action in VALID_ACTIONS:
63
+ return
64
+ print_error(f"Invalid action. Must be one of: {', '.join(VALID_ACTIONS)}")
65
+ raise typer.Exit(1)
66
+
67
+
68
+ # RuleSet commands
69
+ @app.command("list")
70
+ def list_rulesets(
71
+ domain_id: Annotated[str, typer.Argument(help="Domain ID")],
72
+ profile: Annotated[
73
+ str | None,
74
+ typer.Option("--profile", "-p", help="Profile to use"),
75
+ ] = None,
76
+ ) -> None:
77
+ """List rulesets for a domain."""
78
+ try:
79
+ with FastSMTPClient(profile_name=profile) as client:
80
+ rulesets = client.list_rulesets(domain_id)
81
+ if not rulesets:
82
+ print_error("No rulesets found")
83
+ return
84
+ print_rulesets_table(rulesets)
85
+ except APIError as e:
86
+ print_error(e.detail)
87
+ raise typer.Exit(1) from e
88
+
89
+
90
+ @app.command("get")
91
+ def get_ruleset(
92
+ domain_id: Annotated[str, typer.Argument(help="Domain ID")],
93
+ ruleset_id: Annotated[str, typer.Argument(help="RuleSet ID")],
94
+ profile: Annotated[
95
+ str | None,
96
+ typer.Option("--profile", "-p", help="Profile to use"),
97
+ ] = None,
98
+ ) -> None:
99
+ """Get ruleset details with rules."""
100
+ try:
101
+ with FastSMTPClient(profile_name=profile) as client:
102
+ ruleset = client.get_ruleset(domain_id, ruleset_id)
103
+ print_ruleset(ruleset)
104
+ except APIError as e:
105
+ print_error(e.detail)
106
+ raise typer.Exit(1) from e
107
+
108
+
109
+ @app.command("create")
110
+ def create_ruleset(
111
+ domain_id: Annotated[str, typer.Argument(help="Domain ID")],
112
+ name: Annotated[str, typer.Argument(help="RuleSet name")],
113
+ priority: Annotated[
114
+ int,
115
+ typer.Option("--priority", "-P", help="Priority (higher = first)"),
116
+ ] = 0,
117
+ stop_on_match: Annotated[
118
+ bool,
119
+ typer.Option(
120
+ "--stop-on-match/--no-stop-on-match",
121
+ help="Stop evaluating later rulesets once a rule here matches",
122
+ ),
123
+ ] = True,
124
+ profile: Annotated[
125
+ str | None,
126
+ typer.Option("--profile", "-p", help="Profile to use"),
127
+ ] = None,
128
+ ) -> None:
129
+ """Create a new ruleset."""
130
+ try:
131
+ with FastSMTPClient(profile_name=profile) as client:
132
+ ruleset = client.create_ruleset(
133
+ domain_id=domain_id,
134
+ name=name,
135
+ priority=priority,
136
+ stop_on_match=stop_on_match,
137
+ )
138
+ print_success(f"RuleSet '{name}' created")
139
+ print_ruleset(ruleset)
140
+ except APIError as e:
141
+ print_error(e.detail)
142
+ raise typer.Exit(1) from e
143
+
144
+
145
+ @app.command("update")
146
+ def update_ruleset(
147
+ domain_id: Annotated[str, typer.Argument(help="Domain ID")],
148
+ ruleset_id: Annotated[str, typer.Argument(help="RuleSet ID")],
149
+ name: Annotated[
150
+ str | None,
151
+ typer.Option("--name", "-n", help="New name"),
152
+ ] = None,
153
+ priority: Annotated[
154
+ int | None,
155
+ typer.Option("--priority", "-P", help="Priority"),
156
+ ] = None,
157
+ stop_on_match: Annotated[
158
+ bool | None,
159
+ typer.Option("--stop-on-match/--no-stop-on-match", help="Stop on match"),
160
+ ] = None,
161
+ enabled: Annotated[
162
+ bool | None,
163
+ typer.Option("--enabled/--disabled", help="Enable or disable ruleset"),
164
+ ] = None,
165
+ profile: Annotated[
166
+ str | None,
167
+ typer.Option("--profile", "-p", help="Profile to use"),
168
+ ] = None,
169
+ ) -> None:
170
+ """Update a ruleset."""
171
+ if all(value is None for value in [name, priority, stop_on_match, enabled]):
172
+ print_error("At least one option must be provided")
173
+ raise typer.Exit(1)
174
+
175
+ try:
176
+ with FastSMTPClient(profile_name=profile) as client:
177
+ ruleset = client.update_ruleset(
178
+ domain_id=domain_id,
179
+ ruleset_id=ruleset_id,
180
+ name=name,
181
+ priority=priority,
182
+ stop_on_match=stop_on_match,
183
+ is_enabled=enabled,
184
+ )
185
+ print_success("RuleSet updated")
186
+ print_ruleset(ruleset)
187
+ except APIError as e:
188
+ print_error(e.detail)
189
+ raise typer.Exit(1) from e
190
+
191
+
192
+ @app.command("delete")
193
+ def delete_ruleset(
194
+ domain_id: Annotated[str, typer.Argument(help="Domain ID")],
195
+ ruleset_id: Annotated[str, typer.Argument(help="RuleSet ID")],
196
+ force: Annotated[
197
+ bool,
198
+ typer.Option("--force", "-f", help="Skip confirmation"),
199
+ ] = False,
200
+ profile: Annotated[
201
+ str | None,
202
+ typer.Option("--profile", "-p", help="Profile to use"),
203
+ ] = None,
204
+ ) -> None:
205
+ """Delete a ruleset."""
206
+ if not force:
207
+ confirm = typer.confirm(f"Delete ruleset {ruleset_id}?")
208
+ if not confirm:
209
+ raise typer.Exit(0)
210
+
211
+ try:
212
+ with FastSMTPClient(profile_name=profile) as client:
213
+ client.delete_ruleset(domain_id, ruleset_id)
214
+ print_success(f"RuleSet {ruleset_id} deleted")
215
+ except APIError as e:
216
+ print_error(e.detail)
217
+ raise typer.Exit(1) from e
218
+
219
+
220
+ # Rule subcommands
221
+ rule_app = typer.Typer(help="Rule management within a ruleset")
222
+ app.add_typer(rule_app, name="rule")
223
+
224
+
225
+ @rule_app.command("list")
226
+ def list_rules(
227
+ domain_id: Annotated[str, typer.Argument(help="Domain ID")],
228
+ ruleset_id: Annotated[str, typer.Argument(help="RuleSet ID")],
229
+ profile: Annotated[
230
+ str | None,
231
+ typer.Option("--profile", "-p", help="Profile to use"),
232
+ ] = None,
233
+ ) -> None:
234
+ """List rules in a ruleset."""
235
+ try:
236
+ with FastSMTPClient(profile_name=profile) as client:
237
+ rules = client.list_rules(domain_id, ruleset_id)
238
+ if not rules:
239
+ print_error("No rules found")
240
+ return
241
+ print_rules_table(rules)
242
+ except APIError as e:
243
+ print_error(e.detail)
244
+ raise typer.Exit(1) from e
245
+
246
+
247
+ @rule_app.command("get")
248
+ def get_rule(
249
+ domain_id: Annotated[str, typer.Argument(help="Domain ID")],
250
+ ruleset_id: Annotated[str, typer.Argument(help="RuleSet ID")],
251
+ rule_id: Annotated[str, typer.Argument(help="Rule ID")],
252
+ profile: Annotated[
253
+ str | None,
254
+ typer.Option("--profile", "-p", help="Profile to use"),
255
+ ] = None,
256
+ ) -> None:
257
+ """Get rule details."""
258
+ try:
259
+ with FastSMTPClient(profile_name=profile) as client:
260
+ rule = client.get_rule(domain_id, ruleset_id, rule_id)
261
+ print_rule(rule)
262
+ except APIError as e:
263
+ print_error(e.detail)
264
+ raise typer.Exit(1) from e
265
+
266
+
267
+ @rule_app.command("create")
268
+ def create_rule(
269
+ domain_id: Annotated[str, typer.Argument(help="Domain ID")],
270
+ ruleset_id: Annotated[str, typer.Argument(help="RuleSet ID")],
271
+ field: Annotated[
272
+ str,
273
+ typer.Option("--field", "-F", help="Field to match", prompt=True),
274
+ ],
275
+ operator: Annotated[
276
+ str,
277
+ typer.Option(
278
+ "--operator",
279
+ "-O",
280
+ help="Operator (equals, contains, regex, starts_with, ends_with, exists)",
281
+ prompt=True,
282
+ ),
283
+ ],
284
+ value: Annotated[
285
+ str,
286
+ typer.Option("--value", "-V", help="Value to match", prompt=True),
287
+ ],
288
+ action: Annotated[
289
+ str,
290
+ typer.Option("--action", "-a", help="Action (forward, drop, tag, quarantine)"),
291
+ ] = "forward",
292
+ case_sensitive: Annotated[
293
+ bool,
294
+ typer.Option("--case-sensitive/--ignore-case", help="Match case sensitively"),
295
+ ] = False,
296
+ webhook_url: Annotated[
297
+ str | None,
298
+ typer.Option("--webhook-url", "-w", help="Override the webhook URL for this rule"),
299
+ ] = None,
300
+ tags: Annotated[
301
+ list[str] | None,
302
+ typer.Option("--tag", "-t", help="Tag to add when the rule matches (repeatable)"),
303
+ ] = None,
304
+ preserve_raw: Annotated[
305
+ bool,
306
+ typer.Option(
307
+ "--preserve-raw/--no-preserve-raw",
308
+ help="Preserve the raw MIME message in S3 when this rule matches",
309
+ ),
310
+ ] = False,
311
+ profile: Annotated[
312
+ str | None,
313
+ typer.Option("--profile", "-p", help="Profile to use"),
314
+ ] = None,
315
+ ) -> None:
316
+ """Create a new rule, appended to the end of the ruleset."""
317
+ _validate_field(field)
318
+ _validate_operator(operator)
319
+ _validate_action(action)
320
+
321
+ try:
322
+ with FastSMTPClient(profile_name=profile) as client:
323
+ rule = client.create_rule(
324
+ domain_id=domain_id,
325
+ ruleset_id=ruleset_id,
326
+ field=field,
327
+ operator=operator,
328
+ value=value,
329
+ action=action,
330
+ case_sensitive=case_sensitive,
331
+ webhook_url_override=webhook_url,
332
+ add_tags=tags,
333
+ preserve_raw=preserve_raw,
334
+ )
335
+ print_success("Rule created")
336
+ print_rule(rule)
337
+ except APIError as e:
338
+ print_error(e.detail)
339
+ raise typer.Exit(1) from e
340
+
341
+
342
+ @rule_app.command("update")
343
+ def update_rule(
344
+ domain_id: Annotated[str, typer.Argument(help="Domain ID")],
345
+ rule_id: Annotated[str, typer.Argument(help="Rule ID")],
346
+ field: Annotated[
347
+ str | None,
348
+ typer.Option("--field", "-F", help="Field to match"),
349
+ ] = None,
350
+ operator: Annotated[
351
+ str | None,
352
+ typer.Option("--operator", "-O", help="Operator"),
353
+ ] = None,
354
+ value: Annotated[
355
+ str | None,
356
+ typer.Option("--value", "-V", help="Value to match"),
357
+ ] = None,
358
+ action: Annotated[
359
+ str | None,
360
+ typer.Option("--action", "-a", help="Action"),
361
+ ] = None,
362
+ case_sensitive: Annotated[
363
+ bool | None,
364
+ typer.Option("--case-sensitive/--ignore-case", help="Match case sensitively"),
365
+ ] = None,
366
+ webhook_url: Annotated[
367
+ str | None,
368
+ typer.Option(
369
+ "--webhook-url",
370
+ "-w",
371
+ help="Override the webhook URL for this rule; pass '' to remove the override",
372
+ ),
373
+ ] = None,
374
+ tags: Annotated[
375
+ list[str] | None,
376
+ typer.Option("--tag", "-t", help="Tags to add when the rule matches (replaces existing)"),
377
+ ] = None,
378
+ preserve_raw: Annotated[
379
+ bool | None,
380
+ typer.Option(
381
+ "--preserve-raw/--no-preserve-raw",
382
+ help="Preserve the raw MIME message in S3 when this rule matches",
383
+ ),
384
+ ] = None,
385
+ profile: Annotated[
386
+ str | None,
387
+ typer.Option("--profile", "-p", help="Profile to use"),
388
+ ] = None,
389
+ ) -> None:
390
+ """Update a rule."""
391
+ options = [field, operator, value, action, case_sensitive, webhook_url, tags, preserve_raw]
392
+ if all(option is None for option in options):
393
+ print_error("At least one option must be provided")
394
+ raise typer.Exit(1)
395
+
396
+ _validate_field(field)
397
+ _validate_operator(operator)
398
+ _validate_action(action)
399
+
400
+ try:
401
+ with FastSMTPClient(profile_name=profile) as client:
402
+ rule = client.update_rule(
403
+ domain_id=domain_id,
404
+ rule_id=rule_id,
405
+ field=field,
406
+ operator=operator,
407
+ value=value,
408
+ action=action,
409
+ case_sensitive=case_sensitive,
410
+ webhook_url_override=clearable_str(webhook_url),
411
+ add_tags=tags,
412
+ preserve_raw=preserve_raw,
413
+ )
414
+ print_success("Rule updated")
415
+ print_rule(rule)
416
+ except APIError as e:
417
+ print_error(e.detail)
418
+ raise typer.Exit(1) from e
419
+
420
+
421
+ @rule_app.command("delete")
422
+ def delete_rule(
423
+ domain_id: Annotated[str, typer.Argument(help="Domain ID")],
424
+ rule_id: Annotated[str, typer.Argument(help="Rule ID")],
425
+ force: Annotated[
426
+ bool,
427
+ typer.Option("--force", "-f", help="Skip confirmation"),
428
+ ] = False,
429
+ profile: Annotated[
430
+ str | None,
431
+ typer.Option("--profile", "-p", help="Profile to use"),
432
+ ] = None,
433
+ ) -> None:
434
+ """Delete a rule."""
435
+ if not force:
436
+ confirm = typer.confirm(f"Delete rule {rule_id}?")
437
+ if not confirm:
438
+ raise typer.Exit(0)
439
+
440
+ try:
441
+ with FastSMTPClient(profile_name=profile) as client:
442
+ client.delete_rule(domain_id, rule_id)
443
+ print_success(f"Rule {rule_id} deleted")
444
+ except APIError as e:
445
+ print_error(e.detail)
446
+ raise typer.Exit(1) from e
447
+
448
+
449
+ @rule_app.command("reorder")
450
+ def reorder_rules(
451
+ domain_id: Annotated[str, typer.Argument(help="Domain ID")],
452
+ ruleset_id: Annotated[str, typer.Argument(help="RuleSet ID")],
453
+ rule_ids: Annotated[
454
+ list[str],
455
+ typer.Argument(help="Every rule ID in the ruleset, in the desired order"),
456
+ ],
457
+ profile: Annotated[
458
+ str | None,
459
+ typer.Option("--profile", "-p", help="Profile to use"),
460
+ ] = None,
461
+ ) -> None:
462
+ """Set the evaluation order of a ruleset's rules."""
463
+ try:
464
+ with FastSMTPClient(profile_name=profile) as client:
465
+ client.reorder_rules(domain_id, ruleset_id, rule_ids)
466
+ print_success("Rules reordered")
467
+ except APIError as e:
468
+ print_error(e.detail)
469
+ raise typer.Exit(1) from e
@@ -0,0 +1,178 @@
1
+ """User management commands (superuser only).
2
+
3
+ The server's ``UserCreate`` requires only ``username`` and additionally accepts
4
+ ``email`` and ``is_superuser``; ``UserUpdate`` accepts ``username``, ``email``,
5
+ ``is_active`` and ``is_superuser``. Neither carries a password - accounts
6
+ authenticate with API keys (``fsmtp auth create-key``) - so no command here
7
+ offers one. The only query parameter on ``GET /api/v1/users`` and
8
+ ``GET /api/v1/users/{id}`` is ``include_deleted``, exposed as
9
+ ``--include-deleted``.
10
+
11
+ ``delete`` is a soft delete: the user disappears from every listing and can
12
+ no longer authenticate, but ``restore`` brings the account and its domain
13
+ memberships back. The API keys revoked at deletion are never restored.
14
+ ``delete --purge`` removes an already-deleted user for good.
15
+ """
16
+
17
+ from typing import Annotated
18
+
19
+ import typer
20
+
21
+ from fastsmtp_cli.client import APIError, FastSMTPClient
22
+ from fastsmtp_cli.commands.options import IncludeDeleted, Purge, clearable_str, confirm_delete
23
+ from fastsmtp_cli.output import (
24
+ print_deleted,
25
+ print_error,
26
+ print_info,
27
+ print_success,
28
+ print_user,
29
+ print_users_table,
30
+ )
31
+
32
+ app = typer.Typer(help="User management (superuser only)")
33
+
34
+ ProfileOption = Annotated[
35
+ str | None,
36
+ typer.Option("--profile", "-p", help="Profile to use"),
37
+ ]
38
+
39
+
40
+ @app.command("list")
41
+ def list_users(include_deleted: IncludeDeleted = False, profile: ProfileOption = None) -> None:
42
+ """List all users."""
43
+ try:
44
+ with FastSMTPClient(profile_name=profile) as client:
45
+ users = client.list_users(include_deleted=include_deleted)
46
+ if not users:
47
+ print_error("No users found")
48
+ return
49
+ print_users_table(users)
50
+ except APIError as e:
51
+ print_error(e.detail)
52
+ raise typer.Exit(1) from e
53
+
54
+
55
+ @app.command("get")
56
+ def get_user(
57
+ user_id: Annotated[str, typer.Argument(help="User ID")],
58
+ include_deleted: IncludeDeleted = False,
59
+ profile: ProfileOption = None,
60
+ ) -> None:
61
+ """Get user details (a deleted user is not found without --include-deleted)."""
62
+ try:
63
+ with FastSMTPClient(profile_name=profile) as client:
64
+ user = client.get_user(user_id, include_deleted=include_deleted)
65
+ print_user(user)
66
+ except APIError as e:
67
+ print_error(e.detail)
68
+ raise typer.Exit(1) from e
69
+
70
+
71
+ @app.command("create")
72
+ def create_user(
73
+ username: Annotated[str, typer.Argument(help="Username")],
74
+ email: Annotated[
75
+ str | None,
76
+ typer.Option("--email", "-e", help="Email address"),
77
+ ] = None,
78
+ superuser: Annotated[
79
+ bool,
80
+ typer.Option("--superuser", help="Grant superuser privileges"),
81
+ ] = False,
82
+ profile: ProfileOption = None,
83
+ ) -> None:
84
+ """Create a new user."""
85
+ try:
86
+ with FastSMTPClient(profile_name=profile) as client:
87
+ user = client.create_user(username=username, email=email, is_superuser=superuser)
88
+ print_success(f"User '{username}' created")
89
+ print_user(user)
90
+ except APIError as e:
91
+ print_error(e.detail)
92
+ raise typer.Exit(1) from e
93
+
94
+
95
+ @app.command("update")
96
+ def update_user(
97
+ user_id: Annotated[str, typer.Argument(help="User ID")],
98
+ username: Annotated[
99
+ str | None,
100
+ typer.Option("--username", "-u", help="New username"),
101
+ ] = None,
102
+ email: Annotated[
103
+ str | None,
104
+ typer.Option("--email", "-e", help="New email address; pass '' to clear it"),
105
+ ] = None,
106
+ active: Annotated[
107
+ bool | None,
108
+ typer.Option("--active/--inactive", help="Activate or deactivate the account"),
109
+ ] = None,
110
+ superuser: Annotated[
111
+ bool | None,
112
+ typer.Option("--superuser/--no-superuser", help="Grant or revoke superuser privileges"),
113
+ ] = None,
114
+ profile: ProfileOption = None,
115
+ ) -> None:
116
+ """Update a user."""
117
+ if all(value is None for value in (username, email, active, superuser)):
118
+ print_error("At least one option must be provided")
119
+ raise typer.Exit(1)
120
+
121
+ try:
122
+ with FastSMTPClient(profile_name=profile) as client:
123
+ user = client.update_user(
124
+ user_id=user_id,
125
+ username=username,
126
+ email=clearable_str(email),
127
+ is_active=active,
128
+ is_superuser=superuser,
129
+ )
130
+ print_success("User updated")
131
+ print_user(user)
132
+ except APIError as e:
133
+ print_error(e.detail)
134
+ raise typer.Exit(1) from e
135
+
136
+
137
+ @app.command("delete")
138
+ def delete_user(
139
+ user_id: Annotated[str, typer.Argument(help="User ID")],
140
+ force: Annotated[
141
+ bool,
142
+ typer.Option("--force", "-f", help="Skip confirmation"),
143
+ ] = False,
144
+ purge: Purge = False,
145
+ profile: ProfileOption = None,
146
+ ) -> None:
147
+ """Delete a user (restorable), or permanently purge an already-deleted one."""
148
+ confirm_delete("user", user_id, force=force, purge=purge)
149
+
150
+ try:
151
+ with FastSMTPClient(profile_name=profile) as client:
152
+ client.delete_user(user_id, purge=purge)
153
+ print_deleted(
154
+ "user", user_id, purge=purge, restore_command=f"fsmtp users restore {user_id}"
155
+ )
156
+ except APIError as e:
157
+ print_error(e.detail)
158
+ raise typer.Exit(1) from e
159
+
160
+
161
+ @app.command("restore")
162
+ def restore_user(
163
+ user_id: Annotated[str, typer.Argument(help="User ID")],
164
+ profile: ProfileOption = None,
165
+ ) -> None:
166
+ """Restore a deleted user and their domain memberships.
167
+
168
+ API keys revoked at deletion stay revoked; create new ones afterwards.
169
+ """
170
+ try:
171
+ with FastSMTPClient(profile_name=profile) as client:
172
+ user = client.restore_user(user_id)
173
+ print_success("User restored")
174
+ print_user(user)
175
+ print_info("API keys revoked at deletion are not restored; create new keys.")
176
+ except APIError as e:
177
+ print_error(e.detail)
178
+ raise typer.Exit(1) from e