• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

uc-cdis / fence / 13727398361

07 Mar 2025 06:57PM UTC coverage: 75.427% (+0.2%) from 75.268%
13727398361

Pull #1209

github

web-flow
Merge branch 'master' into fix/move_backoff_settings_v2
Pull Request #1209: move backoff settings as well as other functions out of utils

7855 of 10414 relevant lines covered (75.43%)

0.75 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

40.5
fence/scripting/google_monitor.py
1
"""
2
Google Monitoring and Validation Logic
3

4
This file contains scripts to monitor user-registered service accounts and
5
their respective Google projects. The functions in this file will also
6
handle invalid service accounts and projects.
7
"""
8
import traceback
1✔
9

10
from gen3cirrus.google_cloud.iam import GooglePolicyMember
1✔
11
from gen3cirrus import GoogleCloudManager
1✔
12
from gen3cirrus.google_cloud.errors import GoogleAPIError
1✔
13

14
from cdislogging import get_logger
1✔
15

16
import fence.config
1✔
17
from fence.resources.google.validity import (
1✔
18
    GoogleProjectValidity,
19
    GoogleServiceAccountValidity,
20
)
21

22
from fence.resources.google.utils import (
1✔
23
    get_all_registered_service_accounts,
24
    get_linked_google_account_email,
25
    is_google_managed_service_account,
26
)
27

28
from fence.resources.google.access_utils import (
1✔
29
    get_google_project_number,
30
    get_project_from_auth_id,
31
    get_user_by_email,
32
    get_user_by_linked_email,
33
    force_remove_service_account_from_access,
34
    force_remove_service_account_from_db,
35
    user_has_access_to_project,
36
)
37

38
from fence import utils
1✔
39
from fence.config import config
1✔
40
from fence.models import User
1✔
41
from fence.errors import Unauthorized
1✔
42
import requests
1✔
43
from fence.errors import NotFound
1✔
44

45
logger = get_logger(__name__)
1✔
46

47

48
def validation_check(db):
1✔
49
    """
50
    Google validation check for all user-registered service accounts
51
    and projects.
52

53
    This will remove any invalid registered service accounts. It will also
54
    remove all registered service accounts for a given project if the project
55
    itself is invalid.
56

57
    NOTE: This entire function should be time-efficient and finish in less
58
          than 90 seconds.
59
          TODO: Test this function with various amounts of service accounts
60
                and delays from the google API
61
    """
62
    registered_service_accounts = get_all_registered_service_accounts(db=db)
1✔
63
    project_service_account_mapping = _get_project_service_account_mapping(
1✔
64
        registered_service_accounts
65
    )
66

67
    for google_project_id, sa_emails in project_service_account_mapping.items():
1✔
68
        email_required = False
1✔
69
        invalid_registered_service_account_reasons = {}
1✔
70
        invalid_project_reasons = {}
1✔
71
        sa_emails_removed = []
1✔
72
        for sa_email in sa_emails:
1✔
73
            logger.debug("Validating Google Service Account: {}".format(sa_email))
1✔
74
            # Do some basic service account checks, this won't validate
75
            # the data access, that's done when the project's validated
76
            try:
1✔
77
                validity_info = _is_valid_service_account(sa_email, google_project_id)
1✔
78
            except Unauthorized:
×
79
                """
80
                is_validity_service_account can raise an exception if the monitor does
81
                not have access, which will be caught and handled during the Project check below
82
                The logic in the endpoints is reversed (Project is checked first,
83
                not SAs) which is why there's is a sort of weird handling of it here.
84
                """
85
                logger.info(
×
86
                    "Monitor does not have access to validate "
87
                    "service account {}. This should be handled "
88
                    "in project validation.".format(sa_email)
89
                )
90
                continue
×
91

92
            if not validity_info:
1✔
93
                logger.info(
1✔
94
                    "INVALID SERVICE ACCOUNT {} DETECTED. REMOVING. Validity Information: {}".format(
95
                        sa_email, str(getattr(validity_info, "_info", None))
96
                    )
97
                )
98
                force_remove_service_account_from_access(
1✔
99
                    sa_email, google_project_id, db=db
100
                )
101
                if validity_info["policy_accessible"] is False:
1✔
102
                    logger.info(
1✔
103
                        "SERVICE ACCOUNT POLICY NOT ACCESSIBLE OR DOES NOT "
104
                        "EXIST. SERVICE ACCOUNT WILL BE REMOVED FROM FENCE DB"
105
                    )
106
                    force_remove_service_account_from_db(sa_email, db=db)
1✔
107

108
                # remove from list so we don't try to remove again
109
                # if project is invalid too
110
                sa_emails_removed.append(sa_email)
1✔
111

112
                invalid_registered_service_account_reasons[
1✔
113
                    sa_email
114
                ] = _get_service_account_removal_reasons(validity_info)
115
                email_required = True
1✔
116

117
        for sa_email in sa_emails_removed:
1✔
118
            sa_emails.remove(sa_email)
1✔
119

120
        logger.debug("Validating Google Project: {}".format(google_project_id))
1✔
121
        google_project_validity = _is_valid_google_project(google_project_id, db=db)
1✔
122

123
        if not google_project_validity:
1✔
124
            # for now, if we detect in invalid project, remove ALL service
125
            # accounts from access for that project.
126
            #
127
            # TODO: If the issue is ONLY a specific service account,
128
            # it may be possible to isolate it and only remove that
129
            # from access.
130
            logger.info(
×
131
                "INVALID GOOGLE PROJECT {} DETECTED. REMOVING ALL SERVICE ACCOUNTS. "
132
                "Validity Information: {}".format(
133
                    google_project_id,
134
                    str(getattr(google_project_validity, "_info", None)),
135
                )
136
            )
137
            for sa_email in sa_emails:
×
138
                force_remove_service_account_from_access(
×
139
                    sa_email, google_project_id, db=db
140
                )
141

142
            # projects can be invalid for project-related reasons or because
143
            # of NON-registered service accounts
144
            invalid_project_reasons["general"] = _get_general_project_removal_reasons(
×
145
                google_project_validity
146
            )
147
            invalid_project_reasons[
×
148
                "non_registered_service_accounts"
149
            ] = _get_invalid_sa_project_removal_reasons(google_project_validity)
150
            invalid_project_reasons["access"] = _get_access_removal_reasons(
×
151
                google_project_validity
152
            )
153
            email_required = True
×
154

155
        email_required &= config["REMOVE_SERVICE_ACCOUNT_EMAIL_NOTIFICATION"]["enable"]
1✔
156
        if email_required:
1✔
157
            logger.debug(
1✔
158
                "Sending email with service account removal reasons: {} and project "
159
                "removal reasons: {}.".format(
160
                    invalid_registered_service_account_reasons, invalid_project_reasons
161
                )
162
            )
163

164
            try:
1✔
165
                user_email_list = (
1✔
166
                    _get_user_email_list_from_google_project_with_owner_role(
167
                        google_project_id
168
                    )
169
                )
170
            except GoogleAPIError:
×
171
                logger.warning(
×
172
                    "DID NOT EMAIL USERS. Unable to get user(s) email(s) about service account "
173
                    "removal in Google project {}. If fence's monitoring SA is not present "
174
                    "then we cannot make the Google API call to know who to email.".format(
175
                        google_project_id
176
                    )
177
                )
178
                return
×
179

180
            _send_emails_informing_service_account_removal(
1✔
181
                user_email_list,
182
                invalid_registered_service_account_reasons,
183
                invalid_project_reasons,
184
                google_project_id,
185
            )
186

187

188
def _is_valid_service_account(sa_email, google_project_id):
1✔
189
    """
190
    Validate the given registered service account and remove if invalid.
191

192
    Args:
193
        sa_email(str): service account email
194
        google_project_id(str): google project id
195
    """
196
    with GoogleCloudManager(google_project_id) as gcm:
1✔
197
        google_project_number = get_google_project_number(google_project_id, gcm)
1✔
198

199
    has_access = bool(google_project_number)
1✔
200
    if not has_access:
1✔
201
        # if our monitor doesn't have access at this point, just don't return any
202
        # information. When the project check runs, it will catch the monitor missing
203
        # error and add it to the removal reasons
204
        raise Unauthorized(
×
205
            "Google Monitoring SA doesn't have access to Google Project: {}".format(
206
                google_project_id
207
            )
208
        )
209

210
    try:
1✔
211
        sa_validity = GoogleServiceAccountValidity(
1✔
212
            sa_email, google_project_id, google_project_number=google_project_number
213
        )
214

215
        if is_google_managed_service_account(sa_email):
1✔
216
            sa_validity.check_validity(
×
217
                early_return=True,
218
                check_type=True,
219
                check_policy_accessible=True,
220
                check_external_access=False,
221
            )
222
        else:
223
            sa_validity.check_validity(
1✔
224
                early_return=True,
225
                check_type=True,
226
                check_policy_accessible=True,
227
                check_external_access=True,
228
            )
229

230
    except Exception as exc:
×
231
        # any issues, assume invalid
232
        # TODO not sure if this is the right way to handle this...
233
        logger.warning(
×
234
            "Service Account {} determined invalid due to unhandled exception: {}. "
235
            "Assuming service account is invalid.".format(sa_email, str(exc))
236
        )
237
        traceback.print_exc()
×
238
        sa_validity = None
×
239

240
    return sa_validity
1✔
241

242

243
def _is_valid_google_project(google_project_id, db=None):
1✔
244
    """
245
    Validate the given google project id and remove all registered service
246
    accounts under that project if invalid.
247
    """
248
    try:
1✔
249
        project_validity = GoogleProjectValidity(google_project_id)
1✔
250
        project_validity.check_validity(early_return=True, db=db)
1✔
251
    except Exception as exc:
×
252
        # any issues, assume invalid
253
        # TODO not sure if this is the right way to handle this...
254
        logger.warning(
×
255
            "Project {} determined invalid due to unhandled exception: {}. "
256
            "Assuming project is invalid.".format(google_project_id, str(exc))
257
        )
258
        traceback.print_exc()
×
259
        project_validity = None
×
260

261
    return project_validity
1✔
262

263

264
def _get_service_account_removal_reasons(service_account_validity):
1✔
265
    """
266
    Get service account removal reason
267

268
    Args:
269
        service_account_validity(GoogleServiceAccountValidity): service account validity
270

271
    Returns:
272
        List[str]: the reason(s) the service account was removed
273
    """
274
    removal_reasons = []
×
275

276
    if service_account_validity is None:
×
277
        return removal_reasons
×
278

279
    if service_account_validity["valid_type"] is False:
×
280
        removal_reasons.append(
×
281
            "It must be a Compute Engine service account or an user-managed service account."
282
        )
283
    if service_account_validity["no_external_access"] is False:
×
284
        removal_reasons.append(
×
285
            "It has either roles attached to it or service account keys generated. We do not allow this because we need to restrict external access."
286
        )
287
    if service_account_validity["owned_by_project"] is False:
×
288
        removal_reasons.append("It is not owned by the project.")
×
289
    if service_account_validity["policy_accessible"] is False:
×
290
        removal_reasons.append(
×
291
            "Either it doesn't exist in Google or "
292
            "we could not access its policy, "
293
            "which is need for further checks."
294
        )
295

296
    return removal_reasons
×
297

298

299
def _get_general_project_removal_reasons(google_project_validity):
1✔
300
    """
301
    Get service account removal reason
302

303
    Args:
304
        google_project_validity(GoogleProjectValidity): google project validity
305

306
    Returns:
307
        List[str]: the reason(s) project was removed
308
    """
309
    removal_reasons = []
×
310

311
    if google_project_validity is None:
×
312
        return removal_reasons
×
313

314
    if google_project_validity["user_has_access"] is False:
×
315
        removal_reasons.append("User isn't a member on the Google Project.")
×
316

317
    if google_project_validity["monitor_has_access"] is False:
×
318
        removal_reasons.append(
×
319
            "Cannot access the project, ensure monitoring service accounts have necessary permissions."
320
        )
321

322
    if google_project_validity["valid_parent_org"] is False:
×
323
        removal_reasons.append("Google Project has a parent orgnization.")
×
324

325
    if google_project_validity["valid_member_types"] is False:
×
326
        removal_reasons.append(
×
327
            "There are members in the Google Project other than Google Users or Google Service Accounts."
328
        )
329

330
    if google_project_validity["members_exist_in_fence"] is False:
×
331
        removal_reasons.append(
×
332
            "Some Google Users on the Google Project do not exist in authentication database."
333
        )
334

335
    return removal_reasons
×
336

337

338
def _get_invalid_sa_project_removal_reasons(google_project_validity):
1✔
339
    """
340
    Get invalid non-registered service account removal reasons
341

342
    Args:
343
        google_project_validity(GoogleProjectValidity): google project validity
344

345
    Returns:
346
        dict: service_account_email: ["list of of why removed", "more reasons"]
347
    """
348
    removal_reasons = {}
×
349

350
    if google_project_validity is None:
×
351
        return removal_reasons
×
352

353
    for sa_email, sa_validity in google_project_validity.get("service_accounts", {}):
×
354
        if not sa_validity:
×
355
            removal_reasons[sa_email] = _get_service_account_removal_reasons(
×
356
                sa_validity
357
            )
358

359
    return removal_reasons
×
360

361

362
def _get_access_removal_reasons(google_project_validity):
1✔
363
    removal_reasons = {}
×
364

365
    if google_project_validity is None:
×
366
        return removal_reasons
×
367

368
    for project, access_validity in google_project_validity.get("access", {}):
×
369
        removal_reasons[project] = []
×
370
        if access_validity["exists"] is False:
×
371
            removal_reasons[project].append(
×
372
                "Data access project {} no longer exists.".format(project)
373
            )
374

375
        if access_validity["all_users_have_access"] is False:
×
376
            removal_reasons[project].append(
×
377
                "Not all users on the Google Project have access to data project {}.".format(
378
                    project
379
                )
380
            )
381

382
    return removal_reasons
×
383

384

385
def _get_google_project_ids_from_service_accounts(registered_service_accounts):
1✔
386
    """
387
    Return a set of just the google project ids that have registered
388
    service accounts.
389
    """
390
    google_projects = set([sa.google_project_id for sa in registered_service_accounts])
×
391
    return google_projects
×
392

393

394
def _get_project_service_account_mapping(registered_service_accounts):
1✔
395
    """
396
    Return a dict with google projects as keys and a list of service accounts
397
    as values.
398

399
    Example:
400
    {
401
        'project_a': [
402
            'service_acount_a@email.com',
403
            'service_acount_b@email.com'
404
        ],
405
        'project_b': [
406
            'service_acount_c@email.com',
407
            'service_acount_d@email.com'
408
        ]
409
    }
410
    """
411
    output = {}
1✔
412
    for sa in registered_service_accounts:
1✔
413
        if sa.google_project_id in output:
1✔
414
            output[sa.google_project_id].append(sa.email)
1✔
415
        else:
416
            output[sa.google_project_id] = [sa.email]
1✔
417

418
    return output
1✔
419

420

421
def _get_user_email_list_from_google_project_with_owner_role(project_id):
1✔
422
    """
423
    Get a list of emails associated to google project id
424

425
    Args:
426
        project_id(str): project id
427

428
    Returns:
429
        list(str): list of emails belong to the project
430

431
    """
432

433
    with GoogleCloudManager(project_id, use_default=False) as prj:
×
434
        members = prj.get_project_membership(project_id)
×
435
        users = [
×
436
            member
437
            for member in members
438
            if member.member_type == GooglePolicyMember.USER
439
        ]
440

441
        return list(
×
442
            {
443
                u.email_id
444
                for u in users
445
                for role in u.roles
446
                if role.name.upper() == "OWNER"
447
            }
448
        )
449

450

451
def _send_emails_informing_service_account_removal(
1✔
452
    to_emails, invalid_service_account_reasons, invalid_project_reasons, project_id
453
):
454
    """
455
    Send emails to list of emails
456

457
    Args:
458
        to_emails(list(str)): list of email addaresses
459
        invalid_service_account_reasons(dict): removal reasons of service accounts
460
        project_id(str): google project id
461

462
    Returns:
463
        httpResponse or None: None if input list is empty
464

465
    Exceptions:
466
        ValueError
467

468
    """
469

470
    if not to_emails:
×
471
        return None
×
472

473
    from_email = config["REMOVE_SERVICE_ACCOUNT_EMAIL_NOTIFICATION"]["from"]
×
474
    subject = config["REMOVE_SERVICE_ACCOUNT_EMAIL_NOTIFICATION"]["subject"]
×
475

476
    domain = config["REMOVE_SERVICE_ACCOUNT_EMAIL_NOTIFICATION"]["domain"]
×
477
    if config["REMOVE_SERVICE_ACCOUNT_EMAIL_NOTIFICATION"]["admin"]:
×
478
        to_emails.extend(config["REMOVE_SERVICE_ACCOUNT_EMAIL_NOTIFICATION"]["admin"])
×
479

480
    text = config["REMOVE_SERVICE_ACCOUNT_EMAIL_NOTIFICATION"]["content"]
×
481
    content = text.format(project_id)
×
482

483
    for email, removal_reasons in invalid_service_account_reasons.items():
×
484
        if removal_reasons:
×
485
            content += (
×
486
                "\n\t - Service account {} was removed from Google Project {}.".format(
487
                    email, project_id
488
                )
489
            )
490
            for reason in removal_reasons:
×
491
                content += "\n\t\t - {}".format(reason)
×
492

493
    general_project_errors = invalid_project_reasons.get("general", {})
×
494
    non_reg_sa_errors = invalid_project_reasons.get(
×
495
        "non_registered_service_accounts", {}
496
    )
497
    access_errors = invalid_project_reasons.get("access")
×
498
    if general_project_errors or non_reg_sa_errors or access_errors:
×
499
        content += (
×
500
            "\n\t - Google Project {} determined invalid. All service "
501
            "accounts with data access will be removed from access.".format(project_id)
502
        )
503
        for removal_reason in general_project_errors:
×
504
            if removal_reason:
×
505
                content += "\n\t\t - {}".format(removal_reason)
×
506

507
        if access_errors:
×
508
            for project, removal_reasons in access_errors.items():
×
509
                for reason in removal_reasons:
×
510
                    content += "\n\t\t - {}".format(reason)
×
511

512
        if non_reg_sa_errors:
×
513
            for sa_email, removal_reasons in non_reg_sa_errors.items():
×
514
                content += "\n\t\t - Google Project Service Account {} determined invalid.".format(
×
515
                    sa_email
516
                )
517
                for reason in removal_reasons:
×
518
                    content += "\n\t\t\t - {}".format(reason)
×
519

520
    return send_email(from_email, to_emails, subject, content, domain)
×
521

522

523
def _get_users_without_access(db, auth_ids, user_emails, check_linking):
1✔
524
    """
525
    Build list of users without access to projects identified by auth_ids
526

527
    Args:
528
        db (str): database instance
529
        auth_ids (list(str)): list of project auth_ids to check access against
530
        user_emails (list(str)): list of emails to check access for
531
        check_linking (bool): flag to check for linked google email
532

533
    Returns:
534
        dict{str : (list(str))} : dictionary where keys are user emails,
535
        and values are list of project_ids they do not have access to
536

537
    """
538

539
    no_access = {}
1✔
540

541
    for user_email in user_emails:
1✔
542
        user = get_user_by_email(user_email, db) or get_user_by_linked_email(
1✔
543
            user_email, db
544
        )
545

546
        logger.info("Checking access for {}.".format(user.email))
1✔
547

548
        if not user:
1✔
549
            logger.info(
×
550
                "Email ({}) does not exist in fence database.".format(user_email)
551
            )
552
            continue
×
553

554
        if check_linking:
1✔
555
            link_email = get_linked_google_account_email(user.id, db)
×
556
            if not link_email:
×
557
                logger.info(
×
558
                    "User ({}) does not have a linked google account.".format(
559
                        user_email
560
                    )
561
                )
562
                continue
×
563

564
        no_access_auth_ids = []
1✔
565
        for auth_id in auth_ids:
1✔
566
            project = get_project_from_auth_id(auth_id, db)
1✔
567
            if project:
1✔
568
                if not user_has_access_to_project(user, project.id, db):
1✔
569
                    logger.info(
1✔
570
                        "User ({}) does NOT have access to project (auth_id: {})".format(
571
                            user_email, auth_id
572
                        )
573
                    )
574
                    # add to list to send email
575
                    no_access_auth_ids.append(auth_id)
1✔
576
                else:
577
                    logger.info(
1✔
578
                        "User ({}) has access to project (auth_id: {})".format(
579
                            user_email, auth_id
580
                        )
581
                    )
582
            else:
583
                logger.warning("Project (auth_id: {}) does not exist.".format(auth_id))
×
584

585
        if no_access_auth_ids:
1✔
586
            no_access[user_email] = no_access_auth_ids
1✔
587

588
    return no_access
1✔
589

590

591
def email_user_without_access(user_email, projects, google_project_id):
1✔
592
    """
593
    Send email to user, indicating no access to given projects
594

595
    Args:
596
        user_email (str): address to send email to
597
        projects (list(str)):  list of projects user does not have access to that they should
598
        google_project_id (str): id of google project user belongs to
599
    Returns:
600
        HTTP response
601

602
    """
603
    to_emails = [user_email]
×
604

605
    from_email = config["PROBLEM_USER_EMAIL_NOTIFICATION"]["from"]
×
606
    subject = config["PROBLEM_USER_EMAIL_NOTIFICATION"]["subject"]
×
607

608
    domain = config["PROBLEM_USER_EMAIL_NOTIFICATION"]["domain"]
×
609
    if config["PROBLEM_USER_EMAIL_NOTIFICATION"]["admin"]:
×
610
        to_emails.extend(config["PROBLEM_USER_EMAIL_NOTIFICATION"]["admin"])
×
611

612
    text = config["PROBLEM_USER_EMAIL_NOTIFICATION"]["content"]
×
613
    content = text.format(google_project_id, ",".join(projects))
×
614

615
    return send_email(from_email, to_emails, subject, content, domain)
×
616

617

618
def email_users_without_access(
1✔
619
    db, auth_ids, user_emails, check_linking, google_project_id
620
):
621
    """
622
    Build list of users without acess and send emails.
623

624
    Args:
625
        db (str): database instance
626
        auth_ids (list(str)): list of project auth_ids to check access against
627
        user_emails (list(str)): list of emails to check access for
628
        check_linking (bool): flag to check for linked google email
629
    Returns:
630
        None
631
    """
632
    users_without_access = _get_users_without_access(
×
633
        db, auth_ids, user_emails, check_linking
634
    )
635

636
    if len(users_without_access) == len(user_emails):
×
637
        logger.warning(
×
638
            "No user has proper access to provided projects. Contact project administrator. No emails will be sent"
639
        )
640
        return
×
641
    elif len(users_without_access) > 0:
×
642
        logger.info(
×
643
            "Some user(s) do not have proper access to provided projects. Email(s) will be sent to user(s)."
644
        )
645

646
        with GoogleCloudManager(google_project_id) as gcm:
×
647
            members = gcm.get_project_membership(google_project_id)
×
648
            users = []
×
649
            for member in members:
×
650
                if member.member_type == GooglePolicyMember.USER:
×
651
                    users.append(member.email_id)
×
652

653
        for user, projects in users_without_access.items():
×
654
            logger.info(
×
655
                "{} does not have access to the following datasets: {}.".format(
656
                    user, ",".join(projects)
657
                )
658
            )
659
            if user in users:
×
660
                logger.info(
×
661
                    "{} is a member of google project: {}. User will be emailed.".format(
662
                        user, google_project_id
663
                    )
664
                )
665
                email_user_without_access(user, projects, google_project_id)
×
666
            else:
667
                logger.info(
×
668
                    "{} is NOT a member of google project: {}. User will NOT be emailed.".format(
669
                        user, google_project_id
670
                    )
671
                )
672
    else:
673
        logger.info("All users have proper access to provided projects.")
×
674

675

676
def send_email(from_email, to_emails, subject, text, smtp_domain):
1✔
677
    """
678
    Send email to group of emails using mail gun api.
679

680
    https://app.mailgun.com/
681

682
    Args:
683
        from_email(str): from email
684
        to_emails(list): list of emails to receive the messages
685
        text(str): the text message
686
        smtp_domain(dict): smtp domain server
687

688
            {
689
                "smtp_hostname": "smtp.mailgun.org",
690
                "default_login": "postmaster@mailgun.planx-pla.net",
691
                "api_url": "https://api.mailgun.net/v3/mailgun.planx-pla.net",
692
                "smtp_password": "password", # pragma: allowlist secret
693
                "api_key": "api key" # pragma: allowlist secret
694
            }
695

696
    Returns:
697
        Http response
698

699
    Exceptions:
700
        KeyError
701

702
    """
703
    if smtp_domain not in config["GUN_MAIL"] or not config["GUN_MAIL"].get(
×
704
        smtp_domain
705
    ).get("smtp_password"):
706
        raise NotFound(
×
707
            "SMTP Domain '{}' does not exist in configuration for GUN_MAIL or "
708
            "smtp_password was not provided. "
709
            "Cannot send email.".format(smtp_domain)
710
        )
711

712
    api_key = config["GUN_MAIL"][smtp_domain].get("api_key", "")
×
713
    email_url = config["GUN_MAIL"][smtp_domain].get("api_url", "") + "/messages"
×
714

715
    return requests.post(
×
716
        email_url,
717
        auth=("api", api_key),
718
        data={"from": from_email, "to": to_emails, "subject": subject, "text": text},
719
    )
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc